diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a5f3bc304..af8e2abb08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,6 @@ jobs: - 'pnpm-lock.yaml' mobile: - 'mobile/**' - - 'crates/buzz-push-gateway/tests/vectors/**' - 'scripts/mobile-release.sh' - 'scripts/mobile-worktree-overrides.sh' - 'scripts/mobile-worktree-clean.sh' @@ -72,7 +71,6 @@ jobs: - 'scripts/test-mobile-release-contract.sh' - 'scripts/test-mobile-release-candidate-publisher.sh' - 'scripts/test-mobile-worktree-overrides.sh' - - 'scripts/test-ios-pbxproj-semantics.py' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' - name: Release workflow source contract @@ -934,9 +932,6 @@ jobs: run: swift build -c release --package-path mobile/ios/BuzzPushKit - name: Test run: swift test --package-path mobile/ios/BuzzPushKit - - name: Validate iOS project semantics - run: python3 scripts/test-ios-pbxproj-semantics.py - security: name: Security runs-on: ubuntu-latest diff --git a/crates/buzz-push-gateway/Cargo.toml b/crates/buzz-push-gateway/Cargo.toml index c5a5368f3c..06376c02dc 100644 --- a/crates/buzz-push-gateway/Cargo.toml +++ b/crates/buzz-push-gateway/Cargo.toml @@ -7,10 +7,6 @@ rust-version.workspace = true license.workspace = true repository.workspace = true -[features] -default = [] -dev-app-attest-bypass = [] - [lib] name = "buzz_push_gateway" path = "src/lib.rs" diff --git a/crates/buzz-push-gateway/migrations/0002_application_profiles.sql b/crates/buzz-push-gateway/migrations/0002_application_profiles.sql index 45be402dc0..b661f9c3de 100644 --- a/crates/buzz-push-gateway/migrations/0002_application_profiles.sql +++ b/crates/buzz-push-gateway/migrations/0002_application_profiles.sql @@ -15,4 +15,4 @@ ALTER TABLE push_gateway_installations DROP CONSTRAINT push_gateway_installations_app_profile_check; ALTER TABLE push_gateway_installations ADD CONSTRAINT push_gateway_installations_app_profile_check - CHECK (app_profile IN ('buzz-ios-dogfood', 'buzz-ios-app-store')); + CHECK (app_profile = 'buzz-ios-dogfood'); diff --git a/crates/buzz-push-gateway/src/app_attest.rs b/crates/buzz-push-gateway/src/app_attest.rs index 302e2dd5ad..b8f86a35e3 100644 --- a/crates/buzz-push-gateway/src/app_attest.rs +++ b/crates/buzz-push-gateway/src/app_attest.rs @@ -45,14 +45,6 @@ impl AppAttestVerifier { apple_root_cert_pem, }) } - #[cfg(all(test, feature = "dev-app-attest-bypass"))] - pub(crate) fn for_policy_test() -> Self { - Self { - app_id: "policy-test".to_owned(), - apple_root_cert_pem: Vec::new(), - } - } - /// `client_data` is the exact canonical enrollment transcript represented by /// the challenge string passed to `attestKey`; callers must include every /// authority-bearing enrollment field in it. @@ -151,7 +143,6 @@ fn assertion_counter(cbor: &[u8]) -> Result { mod tests { use super::*; use appattest::error::AppAttestError as DependencyAppAttestError; - use chrono::{Duration, NaiveDateTime, Utc}; use serde::Deserialize; const GOOD_FIXTURE_JSON: &str = include_str!("../tests/fixtures/app-attest-good.json"); @@ -165,13 +156,9 @@ mod tests { #[derive(Deserialize)] struct Fixture { description: String, - generator: String, - generated_at: String, - regeneration_command: String, app_id: String, challenge: String, aaguid: String, - leaf_not_after: String, attestation_b64: String, key_id_b64: String, root_cert_pem: String, @@ -180,11 +167,6 @@ mod tests { fn fixture(json: &str) -> Fixture { let fixture: Fixture = serde_json::from_str(json).expect("valid App Attest fixture JSON"); assert!(!fixture.description.is_empty()); - assert_eq!( - fixture.generator, - "crates/buzz-push-gateway/tests/fixtures/app-attest-generator" - ); - assert!(!fixture.generated_at.is_empty()); fixture } @@ -365,111 +347,4 @@ mod tests { .is_err() ); } - - #[test] - fn fixture_leaf_certificate_is_valid_for_at_least_thirty_days() { - let fixture = fixture(GOOD_FIXTURE_JSON); - let leaf_certificate = fixture_leaf_certificate(&fixture); - let not_after = certificate_not_after(&leaf_certificate); - assert_eq!( - not_after.format("%b %e %H:%M:%S %Y GMT").to_string(), - fixture.leaf_not_after - ); - assert!( - not_after > Utc::now() + Duration::days(30), - "App Attest fixture expires within 30 days; regenerate with: {}", - fixture.regeneration_command - ); - } - - fn fixture_leaf_certificate(fixture: &Fixture) -> Vec { - let cbor = STANDARD - .decode(&fixture.attestation_b64) - .expect("fixture attestation is base64"); - let mut decoder = minicbor::Decoder::new(&cbor); - let root_entries = decoder - .map() - .expect("attestation root is a map") - .expect("attestation root map has a fixed length"); - for _ in 0..root_entries { - let key = decoder.str().expect("attestation root key is text"); - if key != "attStmt" { - decoder.skip().expect("skip non-attStmt value"); - continue; - } - let statement_entries = decoder - .map() - .expect("attStmt is a map") - .expect("attStmt map has a fixed length"); - for _ in 0..statement_entries { - let key = decoder.str().expect("attStmt key is text"); - if key != "x5c" { - decoder.skip().expect("skip non-x5c value"); - continue; - } - assert!( - decoder - .array() - .expect("x5c is an array") - .expect("x5c has a fixed length") - >= 2 - ); - return decoder - .bytes() - .expect("x5c leaf certificate is bytes") - .to_vec(); - } - } - panic!("fixture attestation has no x5c leaf certificate"); - } - - fn certificate_not_after(certificate: &[u8]) -> chrono::DateTime { - let (tag, certificate, _) = der_tlv(certificate); - assert_eq!(tag, 0x30, "certificate must be a DER sequence"); - let (tag, tbs_certificate, _) = der_tlv(certificate); - assert_eq!(tag, 0x30, "TBSCertificate must be a DER sequence"); - - let mut fields = tbs_certificate; - if fields.first() == Some(&0xa0) { - fields = der_tlv(fields).2; - } - for _ in 0..3 { - fields = der_tlv(fields).2; - } - let (tag, validity, _) = der_tlv(fields); - assert_eq!(tag, 0x30, "certificate validity must be a DER sequence"); - let (_, _, after_not_before) = der_tlv(validity); - let (time_tag, not_after, _) = der_tlv(after_not_before); - let not_after = std::str::from_utf8(not_after).expect("notAfter is ASCII"); - let format = match time_tag { - 0x17 => "%y%m%d%H%M%SZ", - 0x18 => "%Y%m%d%H%M%SZ", - _ => panic!("unexpected DER time tag {time_tag:#x}"), - }; - NaiveDateTime::parse_from_str(not_after, format) - .expect("valid DER notAfter timestamp") - .and_utc() - } - - fn der_tlv(input: &[u8]) -> (u8, &[u8], &[u8]) { - let tag = *input.first().expect("DER TLV has a tag"); - let first_length = *input.get(1).expect("DER TLV has a length"); - let (length, length_bytes) = if first_length & 0x80 == 0 { - (first_length as usize, 1) - } else { - let byte_count = (first_length & 0x7f) as usize; - assert!( - byte_count > 0 && byte_count <= std::mem::size_of::(), - "supported DER long-form length" - ); - let length = input[2..2 + byte_count] - .iter() - .fold(0_usize, |length, byte| (length << 8) | *byte as usize); - (length, 1 + byte_count) - }; - let value_start = 1 + length_bytes; - let value_end = value_start + length; - assert!(value_end <= input.len(), "DER TLV length is in bounds"); - (tag, &input[value_start..value_end], &input[value_end..]) - } } diff --git a/crates/buzz-push-gateway/src/app_attest_policy.rs b/crates/buzz-push-gateway/src/app_attest_policy.rs deleted file mode 100644 index 77c724d9e6..0000000000 --- a/crates/buzz-push-gateway/src/app_attest_policy.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Selects the production Apple verifier or the feature-gated development stub. - -use crate::app_attest::{ - AppAttestError, AppAttestVerifier, VerifiedAssertion, VerifiedAttestation, -}; -#[cfg(feature = "dev-app-attest-bypass")] -#[derive(Clone)] -pub struct DevelopmentAppAttestPolicy { - _private: (), -} - -#[cfg(feature = "dev-app-attest-bypass")] -pub struct DevelopmentAppAttestBypass { - _private: (), -} - -#[cfg(feature = "dev-app-attest-bypass")] -impl DevelopmentAppAttestBypass { - pub(crate) fn enabled() -> Self { - Self { _private: () } - } -} - -#[cfg_attr( - not(feature = "dev-app-attest-bypass"), - doc = r#" -The development policy is structurally unavailable in default builds: - -```compile_fail -use buzz_push_gateway::app_attest_policy::AppAttestPolicy; - -let _ = AppAttestPolicy::Development; -``` -"# -)] -#[derive(Clone)] -pub enum AppAttestPolicy { - Apple(AppAttestVerifier), - #[cfg(feature = "dev-app-attest-bypass")] - Development(DevelopmentAppAttestPolicy), -} - -impl AppAttestPolicy { - pub fn apple(verifier: AppAttestVerifier) -> Self { - Self::Apple(verifier) - } - - #[cfg(feature = "dev-app-attest-bypass")] - pub fn from_config( - bypass: Option, - apple: AppAttestVerifier, - ) -> Self { - if bypass.is_some() { - tracing::warn!( - "DEVELOPMENT APP ATTEST BYPASS ACTIVE; Apple attestation and assertion verification are disabled" - ); - Self::development() - } else { - Self::apple(apple) - } - } - - #[cfg(feature = "dev-app-attest-bypass")] - fn development() -> Self { - Self::Development(DevelopmentAppAttestPolicy { _private: () }) - } - - pub fn verify_attestation( - &self, - attestation_b64: &str, - key_id_b64: &str, - client_data: &[u8], - ) -> Result { - match self { - Self::Apple(verifier) => { - verifier.verify_attestation(attestation_b64, key_id_b64, client_data) - } - #[cfg(feature = "dev-app-attest-bypass")] - Self::Development(_) => { - crate::dev_app_attest::verify_attestation(attestation_b64, key_id_b64, client_data) - } - } - } - - pub fn verify_assertion( - &self, - assertion_b64: &str, - client_data: &[u8], - public_key: &[u8], - previous_counter: u32, - challenge: &str, - stored_challenge: &str, - ) -> Result { - match self { - Self::Apple(verifier) => verifier.verify_assertion( - assertion_b64, - client_data, - public_key, - previous_counter, - challenge, - stored_challenge, - ), - #[cfg(feature = "dev-app-attest-bypass")] - Self::Development(_) => crate::dev_app_attest::verify_assertion( - assertion_b64, - client_data, - public_key, - previous_counter, - challenge, - stored_challenge, - ), - } - } -} - -#[cfg(test)] -mod tests { - #[test] - fn development_bypass_stays_a_non_default_feature() { - assert!(include_str!("../Cargo.toml") - .contains("[features]\ndefault = []\ndev-app-attest-bypass = []")); - } -} diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index 8e4d551243..f8485a628d 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -1,11 +1,5 @@ -#[cfg(feature = "dev-app-attest-bypass")] -use crate::app_attest_policy::DevelopmentAppAttestBypass; use base64::{engine::general_purpose::STANDARD, Engine as _}; -use std::{ - collections::{HashMap, HashSet}, - net::SocketAddr, - path::PathBuf, -}; +use std::{collections::HashMap, net::SocketAddr, path::PathBuf}; use thiserror::Error; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -16,9 +10,8 @@ pub enum ApnsEnvironment { #[derive(Debug, Clone)] pub struct AppProfileConfig { - pub enabled: bool, pub app_attest_app_id: String, - pub apns_cert_path: Option, + pub apns_cert_path: PathBuf, pub apns_topic: String, pub apns_environment: ApnsEnvironment, } @@ -38,14 +31,10 @@ pub struct Config { pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, pub endpoint_quota_max_deliveries: i64, - /// Closed server-owned registry. Both known application identities are - /// present even when one is dormant; only enabled entries carry a required - /// APNs identity and can enroll or deliver. - pub profiles: HashMap, + /// Server-owned dogfood application identity and APNs transport. + pub profile: AppProfileConfig, pub database_url: String, pub app_attest_root_cert_path: PathBuf, - #[cfg(feature = "dev-app-attest-bypass")] - pub dev_app_attest_bypass: bool, /// Ordered current key first, followed by decrypt-only predecessors. pub grant_keys: Vec, /// Independent token-custody keyring. These keys MUST NOT be reused for @@ -93,31 +82,11 @@ fn parse_keyring( Ok(keys) } -fn parse_profile( - e: &HashMap, - prefix: &'static str, - enabled: bool, -) -> Result { - let app_id_key = match prefix { - "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", - "APP_STORE" => "BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID", - _ => unreachable!("closed profile prefix"), - }; - let cert_key = match prefix { - "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", - "APP_STORE" => "BUZZ_PUSH_APP_STORE_APNS_CERT_PATH", - _ => unreachable!("closed profile prefix"), - }; - let topic_key = match prefix { - "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APNS_TOPIC", - "APP_STORE" => "BUZZ_PUSH_APP_STORE_APNS_TOPIC", - _ => unreachable!("closed profile prefix"), - }; - let environment_key = match prefix { - "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", - "APP_STORE" => "BUZZ_PUSH_APP_STORE_APNS_ENVIRONMENT", - _ => unreachable!("closed profile prefix"), - }; +fn parse_profile(e: &HashMap) -> Result { + let app_id_key = "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID"; + let cert_key = "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH"; + let topic_key = "BUZZ_PUSH_DOGFOOD_APNS_TOPIC"; + let environment_key = "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT"; let required = |key: &'static str| { e.get(key) .map(String::as_str) @@ -126,18 +95,13 @@ fn parse_profile( }; let app_attest_app_id = required(app_id_key)?.to_owned(); let apns_topic = required(topic_key)?.to_owned(); - let apns_cert_path = match e.get(cert_key).filter(|value| !value.is_empty()) { - Some(path) => Some(PathBuf::from(path)), - None if enabled => return Err(ConfigError::Missing(cert_key)), - None => None, - }; + let apns_cert_path = PathBuf::from(required(cert_key)?); let apns_environment = match e.get(environment_key).map(String::as_str) { None | Some("production") => ApnsEnvironment::Production, Some("sandbox") => ApnsEnvironment::Sandbox, Some(_) => return Err(ConfigError::Invalid(environment_key)), }; Ok(AppProfileConfig { - enabled, app_attest_app_id, apns_cert_path, apns_topic, @@ -146,12 +110,6 @@ fn parse_profile( } impl Config { - #[cfg(feature = "dev-app-attest-bypass")] - pub fn dev_app_attest_bypass(&self) -> Option { - self.dev_app_attest_bypass - .then(DevelopmentAppAttestBypass::enabled) - } - pub fn from_env() -> Result { Self::from_map(&std::env::vars().collect()) } @@ -217,35 +175,7 @@ impl Config { bounded_positive("BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS", 10, 86_400)?; let endpoint_quota_max_deliveries = bounded_positive("BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES", 10, 10_000)?; - let enabled_profiles = req(e, "BUZZ_PUSH_ENABLED_PROFILES")? - .split(',') - .map(|profile| match profile { - "buzz-ios-dogfood" => Ok(crate::model::AppProfile::BuzzIosDogfood), - "buzz-ios-app-store" => Ok(crate::model::AppProfile::BuzzIosAppStore), - _ => Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")), - }) - .collect::, _>>()?; - if enabled_profiles.is_empty() { - return Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")); - } - let profiles = HashMap::from([ - ( - crate::model::AppProfile::BuzzIosDogfood, - parse_profile( - e, - "DOGFOOD", - enabled_profiles.contains(&crate::model::AppProfile::BuzzIosDogfood), - )?, - ), - ( - crate::model::AppProfile::BuzzIosAppStore, - parse_profile( - e, - "APP_STORE", - enabled_profiles.contains(&crate::model::AppProfile::BuzzIosAppStore), - )?, - ), - ]); + let profile = parse_profile(e)?; let bind_addr = e .get("BUZZ_PUSH_BIND_ADDR") .map(String::as_str) @@ -258,33 +188,6 @@ impl Config { .unwrap_or("0.0.0.0:8081") .parse::() .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?; - let dev_app_attest_bypass_requested = - match e.get("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS").map(String::as_str) { - None | Some("0") => false, - Some("1") => true, - Some(_) => return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")), - }; - #[cfg(not(feature = "dev-app-attest-bypass"))] - if dev_app_attest_bypass_requested { - return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); - } - #[cfg(feature = "dev-app-attest-bypass")] - let dev_app_attest_bypass = dev_app_attest_bypass_requested; - #[cfg(feature = "dev-app-attest-bypass")] - if dev_app_attest_bypass - && (!bind_addr.ip().is_loopback() || !health_addr.ip().is_loopback()) - { - return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); - } - #[cfg(feature = "dev-app-attest-bypass")] - if dev_app_attest_bypass - && (enabled_profiles.len() != 1 - || !enabled_profiles.contains(&crate::model::AppProfile::BuzzIosDogfood) - || profiles[&crate::model::AppProfile::BuzzIosDogfood].apns_environment - != ApnsEnvironment::Sandbox) - { - return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); - } Ok(Self { bind_addr, health_addr, @@ -293,11 +196,9 @@ impl Config { max_installation_lifetime_seconds, endpoint_quota_window_seconds, endpoint_quota_max_deliveries, - profiles, + profile, database_url: req(e, "DATABASE_URL")?.to_owned(), app_attest_root_cert_path: req(e, "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH")?.into(), - #[cfg(feature = "dev-app-attest-bypass")] - dev_app_attest_bypass, grant_keys, token_keys, }) @@ -307,11 +208,6 @@ impl Config { #[cfg(test)] mod tests { use super::*; - #[cfg(feature = "dev-app-attest-bypass")] - use crate::app_attest_policy::AppAttestPolicy; - #[cfg(feature = "dev-app-attest-bypass")] - use sha2::Digest as _; - fn base() -> HashMap { HashMap::from([ ( @@ -338,10 +234,6 @@ mod tests { "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS".into(), "2592000".into(), ), - ( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood".into(), - ), ( "DATABASE_URL".into(), "postgres://buzz:test@localhost/buzz".into(), // sadscan:disable np.postgres.1 @@ -350,10 +242,6 @@ mod tests { "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID".into(), "TEAM.xyz.block.buzz.dogfood.mobile".into(), ), - ( - "BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID".into(), - "TEAM.xyz.block.buzz.mobile".into(), - ), ( "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH".into(), "/apple-root.pem".into(), @@ -370,36 +258,24 @@ mod tests { "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), "production".into(), ), - ( - "BUZZ_PUSH_APP_STORE_APNS_TOPIC".into(), - "xyz.block.buzz.mobile".into(), - ), ("BUZZ_PUSH_BIND_ADDR".into(), "127.0.0.1:8080".into()), ("BUZZ_PUSH_HEALTH_ADDR".into(), "127.0.0.1:8081".into()), ]) } #[test] - fn enabled_profile_requires_its_certificate_and_all_profiles_have_server_owned_identity() { + fn dogfood_profile_requires_server_owned_identity_and_certificate() { let config = Config::from_map(&base()).unwrap(); - let dogfood = &config.profiles[&crate::model::AppProfile::BuzzIosDogfood]; - assert!(dogfood.enabled); assert_eq!( - dogfood.apns_cert_path, - Some(PathBuf::from("/dogfood-identity.pem")) + config.profile.apns_cert_path, + PathBuf::from("/dogfood-identity.pem") ); - assert_eq!(dogfood.apns_topic, "xyz.block.buzz.dogfood.mobile"); - let app_store = &config.profiles[&crate::model::AppProfile::BuzzIosAppStore]; - assert!(!app_store.enabled); - assert_eq!(app_store.apns_cert_path, None); - assert_eq!(app_store.apns_topic, "xyz.block.buzz.mobile"); + assert_eq!(config.profile.apns_topic, "xyz.block.buzz.dogfood.mobile"); for variable in [ "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", "BUZZ_PUSH_DOGFOOD_APNS_TOPIC", "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", - "BUZZ_PUSH_APP_STORE_APNS_TOPIC", - "BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID", ] { let mut env = base(); env.remove(variable); @@ -432,7 +308,6 @@ mod tests { ), ("BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", ""), ("BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", "staging"), - ("BUZZ_PUSH_ENABLED_PROFILES", "unknown-profile"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "31536001"), ("BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS", "0"), @@ -466,207 +341,6 @@ mod tests { assert_eq!(config.health_addr, "0.0.0.0:8081".parse().unwrap()); } - #[test] - fn dogfood_and_app_store_profiles_parse_together_without_bypass() { - let mut env = base(); - env.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood,buzz-ios-app-store".into(), - ); - env.insert( - "BUZZ_PUSH_APP_STORE_APNS_CERT_PATH".into(), - "/app-store-identity.pem".into(), - ); - - let config = Config::from_map(&env).unwrap(); - assert_eq!(config.profiles.len(), 2); - assert!(config.profiles[&crate::model::AppProfile::BuzzIosDogfood].enabled); - assert!(config.profiles[&crate::model::AppProfile::BuzzIosAppStore].enabled); - } - - #[test] - fn dev_app_attest_bypass_flag_is_strict_and_feature_gated() { - let absent = Config::from_map(&base()).unwrap(); - #[cfg(feature = "dev-app-attest-bypass")] - assert!(!absent.dev_app_attest_bypass); - #[cfg(not(feature = "dev-app-attest-bypass"))] - let _ = absent; - - let mut disabled = base(); - disabled.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "0".into()); - let disabled = Config::from_map(&disabled).unwrap(); - #[cfg(feature = "dev-app-attest-bypass")] - assert!(!disabled.dev_app_attest_bypass); - #[cfg(not(feature = "dev-app-attest-bypass"))] - let _ = disabled; - - for value in ["", "false", "true", "TRUE", "yes", " 1"] { - let mut env = base(); - env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), value.into()); - assert!( - matches!( - Config::from_map(&env), - Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")) - ), - "accepted non-canonical value {value:?}" - ); - } - - #[cfg(not(feature = "dev-app-attest-bypass"))] - { - let mut enabled = base(); - enabled.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - assert!(matches!( - Config::from_map(&enabled), - Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")) - )); - } - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn dev_app_attest_bypass_requires_both_loopback_listeners() { - for (key, value) in [ - ("BUZZ_PUSH_BIND_ADDR", "0.0.0.0:8080"), - ("BUZZ_PUSH_HEALTH_ADDR", "[::]:8081"), - ] { - let mut env = base(); - env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - env.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood".into(), - ); - env.insert( - "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), - "sandbox".into(), - ); - env.insert(key.into(), value.into()); - assert!(Config::from_map(&env).is_err(), "accepted {key}={value}"); - } - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn non_loopback_bind_is_rejected_before_loopback_equivalent_is_accepted() { - let mut non_loopback = base(); - non_loopback.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - non_loopback.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood".into(), - ); - non_loopback.insert( - "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), - "sandbox".into(), - ); - non_loopback.insert("BUZZ_PUSH_BIND_ADDR".into(), "0.0.0.0:8080".into()); - assert!(Config::from_map(&non_loopback).is_err()); - - non_loopback.insert("BUZZ_PUSH_BIND_ADDR".into(), "127.0.0.1:8080".into()); - assert!( - Config::from_map(&non_loopback) - .unwrap() - .dev_app_attest_bypass - ); - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn dev_app_attest_bypass_requires_sandbox_dogfood_as_the_only_profile() { - for profiles in ["buzz-ios-app-store", "buzz-ios-dogfood,buzz-ios-app-store"] { - let mut env = base(); - env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - env.insert("BUZZ_PUSH_ENABLED_PROFILES".into(), profiles.into()); - assert!( - Config::from_map(&env).is_err(), - "accepted profiles {profiles}" - ); - } - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn dev_app_attest_bypass_accepts_explicit_one_for_loopback_sandbox() { - let mut env = base(); - env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - env.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood".into(), - ); - env.insert( - "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), - "sandbox".into(), - ); - assert!(Config::from_map(&env).unwrap().dev_app_attest_bypass); - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn second_profile_is_rejected_before_sandbox_only_is_accepted() { - let mut env = base(); - env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - env.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood,buzz-ios-app-store".into(), - ); - env.insert( - "BUZZ_PUSH_APP_STORE_APNS_CERT_PATH".into(), - "/app-store-identity.pem".into(), - ); - assert!(Config::from_map(&env).is_err()); - - env.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood".into(), - ); - env.insert( - "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), - "sandbox".into(), - ); - assert!(Config::from_map(&env).unwrap().dev_app_attest_bypass); - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn dev_app_attest_bypass_selects_development_policy_from_validated_config() { - let mut env = base(); - env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); - env.insert( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-dogfood".into(), - ); - env.insert( - "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), - "sandbox".into(), - ); - let config = Config::from_map(&env).unwrap(); - let apple = crate::app_attest::AppAttestVerifier::for_policy_test(); - - let policy = AppAttestPolicy::from_config(config.dev_app_attest_bypass(), apple); - - assert!(matches!(policy, AppAttestPolicy::Development(_))); - } - - #[cfg(feature = "dev-app-attest-bypass")] - #[test] - fn bypass_unset_keeps_sentinel_on_the_apple_verifier() { - let config = Config::from_map(&base()).unwrap(); - let policy = AppAttestPolicy::from_config( - config.dev_app_attest_bypass(), - crate::app_attest::AppAttestVerifier::for_policy_test(), - ); - let mut sentinel = b"buzz-dev-app-attest-v1:".to_vec(); - sentinel.extend_from_slice(&[1; 32]); - let key_id = sha2::Sha256::digest(&sentinel); - - assert!(policy - .verify_attestation( - &STANDARD.encode(sentinel), - &STANDARD.encode(key_id), - b"canonical enrollment transcript", - ) - .is_err()); - } - #[test] fn malformed_or_empty_keyrings_fail_startup() { for (variable, value) in [ diff --git a/crates/buzz-push-gateway/src/dev_app_attest.rs b/crates/buzz-push-gateway/src/dev_app_attest.rs deleted file mode 100644 index 038d8b524d..0000000000 --- a/crates/buzz-push-gateway/src/dev_app_attest.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! Explicit development-only App Attest sentinel verification. -//! -//! This module is absent unless the non-default `dev-app-attest-bypass` Cargo -//! feature is enabled. Runtime configuration adds a second, independent gate. - -use crate::app_attest::{AppAttestError, VerifiedAssertion, VerifiedAttestation}; -use base64::{engine::general_purpose::STANDARD, Engine as _}; -use sha2::{Digest, Sha256}; - -const ATTESTATION_PREFIX: &[u8] = b"buzz-dev-app-attest-v1:"; -const ASSERTION_SENTINEL: &[u8] = b"buzz-dev-app-assertion-v1"; -const PUBLIC_KEY_PREFIX: &[u8] = b"buzz-dev-app-attest-public-key-v1:"; -const NONCE_BYTES: usize = 32; - -pub fn assertion_sentinel() -> String { - STANDARD.encode(ASSERTION_SENTINEL) -} - -pub fn verify_attestation( - attestation_b64: &str, - key_id_b64: &str, - client_data: &[u8], -) -> Result { - let attestation = STANDARD - .decode(attestation_b64) - .map_err(|_| AppAttestError::Invalid)?; - let supplied_key_id = STANDARD - .decode(key_id_b64) - .map_err(|_| AppAttestError::Invalid)?; - let expected_key_id = Sha256::digest(&attestation); - if !attestation.starts_with(ATTESTATION_PREFIX) - || attestation.len() != ATTESTATION_PREFIX.len() + NONCE_BYTES - || supplied_key_id.as_slice() != expected_key_id.as_slice() - || client_data.is_empty() - { - return Err(AppAttestError::Invalid); - } - let mut public_key = PUBLIC_KEY_PREFIX.to_vec(); - public_key.extend_from_slice(&expected_key_id); - Ok(VerifiedAttestation { - key_id: expected_key_id.to_vec(), - public_key, - }) -} - -pub fn verify_assertion( - assertion_b64: &str, - client_data: &[u8], - public_key: &[u8], - previous_counter: u32, - challenge: &str, - stored_challenge: &str, -) -> Result { - let assertion = STANDARD - .decode(assertion_b64) - .map_err(|_| AppAttestError::Invalid)?; - if assertion != ASSERTION_SENTINEL - || client_data.is_empty() - || !public_key.starts_with(PUBLIC_KEY_PREFIX) - || public_key.len() != PUBLIC_KEY_PREFIX.len() + 32 - || challenge.is_empty() - || challenge != stored_challenge - { - return Err(AppAttestError::Invalid); - } - Ok(VerifiedAssertion { - counter: previous_counter - .checked_add(1) - .ok_or(AppAttestError::Invalid)?, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn attestation(nonce: u8) -> (String, String) { - let mut sentinel = ATTESTATION_PREFIX.to_vec(); - sentinel.extend_from_slice(&[nonce; NONCE_BYTES]); - let key_id = Sha256::digest(&sentinel); - (STANDARD.encode(sentinel), STANDARD.encode(key_id)) - } - - #[test] - fn versioned_attestation_derives_a_unique_bound_key_id() { - let (attestation_a, key_id_a) = attestation(1); - let verified_a = verify_attestation( - &attestation_a, - &key_id_a, - b"canonical enrollment transcript", - ) - .unwrap(); - assert_eq!(verified_a.key_id.len(), 32); - assert!(verified_a.public_key.starts_with(PUBLIC_KEY_PREFIX)); - - let (attestation_b, key_id_b) = attestation(2); - let verified_b = verify_attestation( - &attestation_b, - &key_id_b, - b"canonical enrollment transcript", - ) - .unwrap(); - assert_ne!(verified_a.key_id, verified_b.key_id); - - for (attestation, key, transcript) in [ - ("bad".to_owned(), key_id_a.clone(), b"transcript".as_slice()), - (attestation_a.clone(), key_id_b, b"transcript"), - (attestation_a, key_id_a, b""), - ] { - assert!(verify_attestation(&attestation, &key, transcript).is_err()); - } - } - - #[test] - fn bad_attestation_sentinel_is_rejected_before_good_sentinel_is_accepted() { - let (good_attestation, key_id) = attestation(1); - let bad_attestation = STANDARD.encode(b"buzz-dev-app-attest-v1:bad"); - - assert!(verify_attestation(&bad_attestation, &key_id, b"transcript").is_err()); - assert!(verify_attestation(&good_attestation, &key_id, b"transcript").is_ok()); - } - - #[test] - fn exact_assertion_sentinel_and_stored_dev_marker_advance_counter() { - let (attestation, key_id) = attestation(1); - let public_key = verify_attestation(&attestation, &key_id, b"transcript") - .unwrap() - .public_key; - let verified = verify_assertion( - &assertion_sentinel(), - b"canonical assertion transcript", - &public_key, - 7, - "challenge", - "challenge", - ) - .unwrap(); - assert_eq!(verified.counter, 8); - - assert!(verify_assertion( - "bad", - b"canonical assertion transcript", - &public_key, - 7, - "challenge", - "challenge", - ) - .is_err()); - assert!(verify_assertion( - &assertion_sentinel(), - b"canonical assertion transcript", - b"not-the-development-marker", - 7, - "challenge", - "challenge", - ) - .is_err()); - assert!(verify_assertion( - &assertion_sentinel(), - b"canonical assertion transcript", - &public_key, - u32::MAX, - "challenge", - "challenge", - ) - .is_err()); - } -} diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 4033825d36..b300dd8637 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -1,7 +1,7 @@ //! Stateful installation, delegation, delivery, and health APIs. use crate::{ apns::{DeliveryAttempt, DeliveryOutcome, PushTransport}, - app_attest_policy::AppAttestPolicy, + app_attest::AppAttestVerifier, authority::{ AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation, }, @@ -23,7 +23,6 @@ use nostr::{ Event, JsonUtil, Timestamp, }; use std::{ - collections::HashMap, sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -35,15 +34,8 @@ use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer}; #[derive(Clone)] pub struct ProfileRuntime { - /// Both values are absent for a registered but dormant profile. - pub app_attest: Option>, - pub transport: Option>, -} - -impl ProfileRuntime { - fn enabled(&self) -> bool { - self.app_attest.is_some() && self.transport.is_some() - } + pub app_attest: Arc, + pub transport: Arc, } #[derive(Clone)] @@ -51,10 +43,9 @@ pub struct AppState { pub grant_keyring: Arc, pub authority: Arc, pub token_keyring: Arc, - /// Closed server-owned app identity registry. Client profile selectors - /// choose only a candidate; App Attest verifies the configured application - /// ID before the profile is persisted as installation authority. - pub profiles: Arc>, + /// Server-owned dogfood application identity and APNs transport. The wire + /// profile selector is fixed and App Attest verifies the configured app ID. + pub profile: Arc, pub delivery_url: url::Url, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, @@ -177,10 +168,9 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Some(v) => v, None => return error(StatusCode::BAD_REQUEST, "invalid_request"), }; - let profile = match s.profiles.get(&r.app_profile) { - Some(profile) if profile.enabled() => profile, - _ => return error(StatusCode::BAD_REQUEST, "invalid_request"), - }; + if r.app_profile != AppProfile::BuzzIosDogfood { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } if r.v != WIRE_VERSION || r.endpoint_epoch != 1 || r.expires_at <= now @@ -207,14 +197,15 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Some(v) => v, None => return error(StatusCode::BAD_REQUEST, "invalid_request"), }; - let verified = match profile.app_attest.as_ref().and_then(|policy| { - policy + let verified = + match s + .profile + .app_attest .verify_attestation(&r.attestation, &r.key_id, signed.as_bytes()) - .ok() - }) { - Some(value) => value, - None => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), - }; + { + Ok(value) => value, + Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), + }; if let Err(e) = s .authority .consume_challenge(r.challenge_id, challenge, now) @@ -269,14 +260,14 @@ async fn verify_installation_assertion( .installation(installation_id, now) .await .map_err(authority_error)?; - let app_attest = s - .profiles - .get(&installation.profile) - .and_then(|profile| profile.app_attest.as_ref()) - .ok_or_else(|| error(StatusCode::NOT_FOUND, "not_authorized"))?; + if installation.profile != AppProfile::BuzzIosDogfood { + return Err(error(StatusCode::NOT_FOUND, "not_authorized")); + } let transcript = transcript(domain, signed) .ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?; - let verified = app_attest + let verified = s + .profile + .app_attest .verify_assertion( assertion, transcript.as_bytes(), @@ -655,23 +646,15 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> .await; return error(StatusCode::NOT_FOUND, "invalid_grant"); } - let profile = permit.authority.profile; - let transport = match s - .profiles - .get(&profile) - .and_then(|runtime| runtime.transport.as_ref()) - .cloned() - { - Some(transport) => transport, - None => { - crate::metrics::record_delivery_error("profile_disabled"); - let _ = s - .authority - .finish_delivery(permit, DeliveryDisposition::Retryable) - .await; - return error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault"); - } - }; + if permit.authority.profile != AppProfile::BuzzIosDogfood { + crate::metrics::record_delivery_error("profile_disabled"); + let _ = s + .authority + .finish_delivery(permit, DeliveryDisposition::Retryable) + .await; + return error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault"); + } + let transport = Arc::clone(&s.profile.transport); let endpoint = match s.token_keyring.open(&permit.authority.token_ciphertext) { Ok(token) => hex::encode(token), Err(_) => { diff --git a/crates/buzz-push-gateway/src/lib.rs b/crates/buzz-push-gateway/src/lib.rs index 59fa2ca09b..563d725db9 100644 --- a/crates/buzz-push-gateway/src/lib.rs +++ b/crates/buzz-push-gateway/src/lib.rs @@ -1,11 +1,8 @@ //! Stateful, capability-gated APNs last hop for NIP-PL. pub mod apns; pub mod app_attest; -pub mod app_attest_policy; pub mod authority; pub mod config; -#[cfg(feature = "dev-app-attest-bypass")] -pub mod dev_app_attest; pub mod grant; pub mod http; pub mod metrics; diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index a2613dfb37..db35b25110 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -1,7 +1,6 @@ use buzz_push_gateway::{ apns::ApnsTransport, app_attest::AppAttestVerifier, - app_attest_policy::AppAttestPolicy, authority::AuthorityStore, config::Config, grant::{GrantKey, GrantKeyring}, @@ -11,7 +10,6 @@ use buzz_push_gateway::{ AppState, }; use std::{ - collections::HashMap, fs, sync::{ atomic::{AtomicBool, Ordering}, @@ -38,40 +36,22 @@ async fn main() -> Result<(), Box> { let c = Config::from_env()?; let metrics_handle = buzz_push_gateway::metrics::install()?; let app_attest_root = fs::read(&c.app_attest_root_cert_path)?; - let mut profiles = HashMap::new(); - for (profile, configured) in &c.profiles { - let runtime = if configured.enabled { - let cert_path = configured.apns_cert_path.as_ref().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "enabled push profile has no APNs identity path", - ) - })?; - let transport = Arc::new(ApnsTransport::certificate( - &fs::read(cert_path)?, - configured.apns_topic.clone(), - configured.apns_environment, - )?); - let apple = AppAttestVerifier::new( - configured.app_attest_app_id.clone(), - app_attest_root.clone(), - )?; - #[cfg(feature = "dev-app-attest-bypass")] - let policy = AppAttestPolicy::from_config(c.dev_app_attest_bypass(), apple); - #[cfg(not(feature = "dev-app-attest-bypass"))] - let policy = AppAttestPolicy::apple(apple); - buzz_push_gateway::http::ProfileRuntime { - app_attest: Some(Arc::new(policy)), - transport: Some(transport), - } - } else { - buzz_push_gateway::http::ProfileRuntime { - app_attest: None, - transport: None, - } - }; - profiles.insert(*profile, runtime); - } + let configured = &c.profile; + let profile = { + let transport = Arc::new(ApnsTransport::certificate( + &fs::read(&configured.apns_cert_path)?, + configured.apns_topic.clone(), + configured.apns_environment, + )?); + let apple = AppAttestVerifier::new( + configured.app_attest_app_id.clone(), + app_attest_root.clone(), + )?; + buzz_push_gateway::http::ProfileRuntime { + app_attest: Arc::new(apple), + transport, + } + }; let grant_keyring = GrantKeyring::new( c.grant_keys .iter() @@ -114,7 +94,7 @@ async fn main() -> Result<(), Box> { grant_keyring: Arc::new(grant_keyring), authority, token_keyring: Arc::new(token_keyring), - profiles: Arc::new(profiles), + profile: Arc::new(profile), delivery_url: c.public_delivery_url, max_grant_lifetime_seconds: c.max_grant_lifetime_seconds, max_installation_lifetime_seconds: c.max_installation_lifetime_seconds, diff --git a/crates/buzz-push-gateway/src/model.rs b/crates/buzz-push-gateway/src/model.rs index 574d1d6b9c..bcb5cc947d 100644 --- a/crates/buzz-push-gateway/src/model.rs +++ b/crates/buzz-push-gateway/src/model.rs @@ -13,13 +13,11 @@ pub const WIRE_VERSION: u8 = 1; #[serde(rename_all = "kebab-case")] pub enum AppProfile { BuzzIosDogfood, - BuzzIosAppStore, } impl AppProfile { pub const fn as_str(self) -> &'static str { match self { Self::BuzzIosDogfood => "buzz-ios-dogfood", - Self::BuzzIosAppStore => "buzz-ios-app-store", } } } diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index a3ea82c823..8492ac12c5 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -64,7 +64,6 @@ fn ts(v: DateTime) -> i64 { fn profile(v: &str) -> Result { match v { "buzz-ios-dogfood" => Ok(AppProfile::BuzzIosDogfood), - "buzz-ios-app-store" => Ok(AppProfile::BuzzIosAppStore), _ => Err(AuthorityError::Unavailable), } } @@ -409,7 +408,7 @@ mod tests { use super::*; use sqlx::{postgres::PgPoolOptions, AssertSqlSafe}; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials #[tokio::test] #[ignore = "requires PostgreSQL with CREATEDB/CREATEROLE"] diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock deleted file mode 100644 index d29012deec..0000000000 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock +++ /dev/null @@ -1,341 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "buzz-app-attest-fixture-generator" -version = "0.1.0" -dependencies = [ - "base64", - "byteorder", - "ciborium", - "openssl", - "sha2", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "cc" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml deleted file mode 100644 index 2ffc6d243f..0000000000 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "buzz-app-attest-fixture-generator" -version = "0.1.0" -edition = "2021" -publish = false - -[workspace] - -[dependencies] -base64 = "0.22" -byteorder = "1.5" -ciborium = "0.2" -openssl = "0.10" -sha2 = "0.10" diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md deleted file mode 100644 index cf498bf1d6..0000000000 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# App Attest verifier fixtures - -This standalone crate generates synthetic App Attest fixtures for the gateway's strict verifier tests. It has its own `[workspace]`, does not belong to the repository workspace dependency graph, and must never depend on `appattest`. That separation prevents the dependency's `testing` feature from being unified into the gateway test build, where it would permit the development AAGUID. - -The generator owns the full root, intermediate, and credential certificate chain. It writes Apple's nonce extension OID `1.2.840.113635.100.8.2` and App Attest EKU `1.2.840.113635.100.4.24` directly. Generator correctness is therefore load-bearing. The strict verifier's good-fixture acceptance test is the encoding oracle. Any generator change must pass the full App Attest test floor, and a fixture rejected by the shipped verifier must never be made green by loosening a test. - -`apple-app-attestation-root.pem` is the production pin-control fixture downloaded from [Apple's certificate authority](https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem). Its exact PEM-file SHA-256 must remain `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. - -Regenerate from the repository root: - -```bash -cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- \ - --output-dir crates/buzz-push-gateway/tests/fixtures \ - --good-aaguid appattest \ - --wrong-aaguid appattestdevelop -``` - -The command rewrites `app-attest-good.json`, `app-attest-wrong-aaguid.json`, and `app-attest-wrong-root.json`. Review all fixture changes and run the complete gateway package test suite afterward. diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs deleted file mode 100644 index fe367fbdaa..0000000000 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs +++ /dev/null @@ -1,404 +0,0 @@ -use std::{ - env, fs, - path::{Path, PathBuf}, -}; - -use base64::{engine::general_purpose::STANDARD, Engine as _}; -use byteorder::{BigEndian, ByteOrder}; -use ciborium::{cbor, Value}; -use openssl::{ - asn1::{Asn1Integer, Asn1Object, Asn1OctetString, Asn1Time}, - bn::{BigNum, MsbOption}, - ec::{EcGroup, EcKey, PointConversionForm}, - hash::MessageDigest, - nid::Nid, - pkey::{PKey, Private}, - x509::{ - extension::{BasicConstraints, ExtendedKeyUsage, KeyUsage}, - X509Builder, X509Extension, X509Name, X509NameBuilder, X509, - }, -}; -use sha2::{Digest, Sha256}; - -const APP_ID: &str = "TEAMID.xyz.buzz.mobile"; -const CHALLENGE: &str = "buzz-app-attest-strict-verifier-fixture"; -const CERT_VALIDITY_DAYS: u32 = 7_305; -const REGENERATION_COMMAND: &str = "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop"; - -struct Args { - output_dir: PathBuf, - good_aaguid: String, - wrong_aaguid: String, -} - -struct CertificateAuthority { - root_cert: X509, - intermediate_cert: X509, - intermediate_key: PKey, -} - -struct Fixture { - attestation_b64: String, - key_id_b64: String, - root_cert_pem: String, - leaf_not_after: String, -} - -fn main() { - let args = parse_args(); - validate_aaguid(&args.good_aaguid); - validate_aaguid(&args.wrong_aaguid); - fs::create_dir_all(&args.output_dir).expect("create fixture output directory"); - - let primary_ca = CertificateAuthority::generate("Buzz App Attest Fixture Root"); - let good = build_fixture(&primary_ca, &args.good_aaguid); - let wrong_aaguid = build_fixture(&primary_ca, &args.wrong_aaguid); - - let unrelated_ca = CertificateAuthority::generate("Unrelated App Attest Fixture Root"); - let wrong_root = build_fixture(&unrelated_ca, &args.good_aaguid); - - write_fixture( - &args.output_dir, - "app-attest-good.json", - "Valid synthetic Apple App Attest attestation for the gateway strict-verifier acceptance control.", - &args.good_aaguid, - &good, - ); - write_fixture( - &args.output_dir, - "app-attest-wrong-aaguid.json", - "Synthetic attestation with a development AAGUID and a correctly recomputed nonce; the strict verifier must report InvalidAAGUID.", - &args.wrong_aaguid, - &wrong_aaguid, - ); - write_fixture( - &args.output_dir, - "app-attest-wrong-root.json", - "Internally valid synthetic attestation signed by an unrelated root; the verifier configured with the good fixture root must reject it.", - &args.good_aaguid, - &wrong_root, - ); -} - -fn parse_args() -> Args { - let mut output_dir = None; - let mut good_aaguid = None; - let mut wrong_aaguid = None; - let mut args = env::args().skip(1); - while let Some(arg) = args.next() { - let value = args - .next() - .unwrap_or_else(|| panic!("missing value for {arg}")); - match arg.as_str() { - "--output-dir" => output_dir = Some(PathBuf::from(value)), - "--good-aaguid" => good_aaguid = Some(value), - "--wrong-aaguid" => wrong_aaguid = Some(value), - _ => panic!("unknown argument {arg}"), - } - } - Args { - output_dir: output_dir.expect("--output-dir is required"), - good_aaguid: good_aaguid.expect("--good-aaguid is required"), - wrong_aaguid: wrong_aaguid.expect("--wrong-aaguid is required"), - } -} - -fn validate_aaguid(aaguid: &str) { - assert!( - !aaguid.is_empty() && aaguid.len() <= 16 && aaguid.is_ascii(), - "AAGUID must be 1 to 16 ASCII bytes" - ); -} - -impl CertificateAuthority { - fn generate(root_common_name: &str) -> Self { - let root_key = p384_key(); - let root_name = name(root_common_name); - let root_cert = certificate( - &root_name, - &root_name, - &root_key, - &root_key, - MessageDigest::sha384(), - true, - None, - ); - - let intermediate_key = p256_key(); - let intermediate_name = name("Buzz App Attest Fixture Intermediate"); - let intermediate_cert = certificate( - &intermediate_name, - root_cert.subject_name(), - &intermediate_key, - &root_key, - MessageDigest::sha384(), - true, - None, - ); - - Self { - root_cert, - intermediate_cert, - intermediate_key, - } - } -} - -fn build_fixture(ca: &CertificateAuthority, aaguid_value: &str) -> Fixture { - let device_key = p256_key(); - let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).expect("P-256 group"); - let ec_key = device_key.ec_key().expect("device EC key"); - let mut context = openssl::bn::BigNumContext::new().expect("bignum context"); - let public_key = ec_key - .public_key() - .to_bytes(&group, PointConversionForm::UNCOMPRESSED, &mut context) - .expect("serialize device public key"); - let key_id = Sha256::digest(&public_key); - let key_id_b64 = STANDARD.encode(key_id); - - let mut aaguid = [0_u8; 16]; - aaguid[..aaguid_value.len()].copy_from_slice(aaguid_value.as_bytes()); - let mut auth_data = Vec::with_capacity(87); - auth_data.extend_from_slice(&Sha256::digest(APP_ID.as_bytes())); - auth_data.push(0x41); - auth_data.extend_from_slice(&[0_u8; 4]); - auth_data.extend_from_slice(&aaguid); - let mut credential_length = [0_u8; 2]; - BigEndian::write_u16(&mut credential_length, key_id.len() as u16); - auth_data.extend_from_slice(&credential_length); - auth_data.extend_from_slice(&key_id); - - let mut nonce_input = auth_data.clone(); - nonce_input.extend_from_slice(&Sha256::digest(CHALLENGE.as_bytes())); - let nonce = Sha256::digest(nonce_input); - - let leaf_name = name("Buzz App Attest Fixture Credential"); - let leaf_cert = certificate( - &leaf_name, - ca.intermediate_cert.subject_name(), - &device_key, - &ca.intermediate_key, - MessageDigest::sha256(), - false, - Some(&nonce), - ); - let leaf_der = leaf_cert.to_der().expect("encode credential certificate"); - let intermediate_der = ca - .intermediate_cert - .to_der() - .expect("encode intermediate certificate"); - let value = cbor!({ - "fmt" => "apple-appattest", - "attStmt" => { - "x5c" => [ - Value::Bytes(leaf_der), - Value::Bytes(intermediate_der) - ], - "receipt" => Value::Bytes(Vec::new()) - }, - "authData" => Value::Bytes(auth_data) - }) - .expect("build attestation CBOR value"); - let mut cbor = Vec::new(); - ciborium::into_writer(&value, &mut cbor).expect("encode attestation CBOR"); - - Fixture { - attestation_b64: STANDARD.encode(cbor), - key_id_b64, - root_cert_pem: String::from_utf8( - ca.root_cert.to_pem().expect("encode root certificate PEM"), - ) - .expect("root PEM is UTF-8"), - leaf_not_after: leaf_cert.not_after().to_string(), - } -} - -fn certificate( - subject: &X509Name, - issuer: &openssl::x509::X509NameRef, - subject_key: &PKey, - issuer_key: &PKey, - signature_digest: MessageDigest, - is_ca: bool, - nonce: Option<&[u8]>, -) -> X509 { - let mut builder = X509Builder::new().expect("create certificate builder"); - builder.set_version(2).expect("set X.509 version"); - builder - .set_serial_number(&serial_number()) - .expect("set certificate serial"); - builder - .set_subject_name(subject) - .expect("set certificate subject"); - builder - .set_issuer_name(issuer) - .expect("set certificate issuer"); - builder - .set_pubkey(subject_key) - .expect("set certificate public key"); - builder - .set_not_before(&Asn1Time::days_from_now(0).expect("build notBefore")) - .expect("set notBefore"); - builder - .set_not_after(&Asn1Time::days_from_now(CERT_VALIDITY_DAYS).expect("build long notAfter")) - .expect("set notAfter"); - - if is_ca { - builder - .append_extension( - BasicConstraints::new() - .critical() - .ca() - .build() - .expect("build CA constraints"), - ) - .expect("append CA constraints"); - builder - .append_extension( - KeyUsage::new() - .critical() - .key_cert_sign() - .crl_sign() - .build() - .expect("build CA key usage"), - ) - .expect("append CA key usage"); - } else { - builder - .append_extension( - BasicConstraints::new() - .critical() - .build() - .expect("build leaf constraints"), - ) - .expect("append leaf constraints"); - builder - .append_extension( - ExtendedKeyUsage::new() - .other("1.2.840.113635.100.4.24") - .build() - .expect("build App Attest EKU"), - ) - .expect("append App Attest EKU"); - - let nonce = nonce.expect("credential certificate nonce"); - assert_eq!(nonce.len(), 32, "App Attest nonce must be 32 bytes"); - let mut extension_value = Vec::with_capacity(38); - extension_value.extend_from_slice(&[0x30, 0x24, 0xa1, 0x22, 0x04, 0x20]); - extension_value.extend_from_slice(nonce); - let oid = Asn1Object::from_str("1.2.840.113635.100.8.2") - .expect("parse App Attest nonce extension OID"); - let octets = Asn1OctetString::new_from_bytes(&extension_value) - .expect("encode App Attest nonce extension"); - builder - .append_extension( - X509Extension::new_from_der(&oid, false, &octets) - .expect("build App Attest nonce extension"), - ) - .expect("append App Attest nonce extension"); - } - - builder - .sign(issuer_key, signature_digest) - .expect("sign certificate"); - builder.build() -} - -fn p256_key() -> PKey { - ec_key(Nid::X9_62_PRIME256V1) -} - -fn p384_key() -> PKey { - ec_key(Nid::SECP384R1) -} - -fn ec_key(curve: Nid) -> PKey { - let group = EcGroup::from_curve_name(curve).expect("EC group"); - PKey::from_ec_key(EcKey::generate(&group).expect("generate EC key")).expect("wrap EC key") -} - -fn name(common_name: &str) -> X509Name { - let mut builder = X509NameBuilder::new().expect("create X.509 name builder"); - builder - .append_entry_by_text("CN", common_name) - .expect("set common name"); - builder - .append_entry_by_text("O", "Buzz") - .expect("set organization"); - builder.build() -} - -fn serial_number() -> Asn1Integer { - let mut number = BigNum::new().expect("create certificate serial"); - number - .rand(128, MsbOption::MAYBE_ZERO, false) - .expect("generate certificate serial"); - Asn1Integer::from_bn(&number).expect("convert certificate serial") -} - -fn write_fixture( - output_dir: &Path, - file_name: &str, - description: &str, - aaguid: &str, - fixture: &Fixture, -) { - let generated_at = generation_date(); - let json = format!( - concat!( - "{{\n", - " \"description\": \"{}\",\n", - " \"generator\": \"crates/buzz-push-gateway/tests/fixtures/app-attest-generator\",\n", - " \"generated_at\": \"{}\",\n", - " \"regeneration_command\": \"{}\",\n", - " \"app_id\": \"{}\",\n", - " \"challenge\": \"{}\",\n", - " \"aaguid\": \"{}\",\n", - " \"leaf_not_after\": \"{}\",\n", - " \"attestation_b64\": \"{}\",\n", - " \"key_id_b64\": \"{}\",\n", - " \"root_cert_pem\": \"{}\"\n", - "}}\n" - ), - json_escape(description), - generated_at, - json_escape(REGENERATION_COMMAND), - APP_ID, - CHALLENGE, - aaguid, - json_escape(&fixture.leaf_not_after), - fixture.attestation_b64, - fixture.key_id_b64, - json_escape(&fixture.root_cert_pem), - ); - fs::write(output_dir.join(file_name), json).expect("write fixture JSON"); -} - -fn generation_date() -> String { - let output = std::process::Command::new("date") - .args(["-u", "+%Y-%m-%d"]) - .output() - .expect("run date for fixture metadata"); - assert!(output.status.success(), "date command failed"); - String::from_utf8(output.stdout) - .expect("date output is UTF-8") - .trim() - .to_owned() -} - -fn json_escape(value: &str) -> String { - value - .chars() - .flat_map(|character| match character { - '\\' => "\\\\".chars().collect::>(), - '"' => "\\\"".chars().collect(), - '\n' => "\\n".chars().collect(), - '\r' => "\\r".chars().collect(), - '\t' => "\\t".chars().collect(), - value if value.is_control() => { - panic!("unsupported control character in fixture metadata") - } - value => vec![value], - }) - .collect() -} diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json index 5c076ba951..3bdff5ccdc 100644 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json @@ -1,12 +1,8 @@ { "description": "Valid synthetic Apple App Attest attestation for the gateway strict-verifier acceptance control.", - "generator": "crates/buzz-push-gateway/tests/fixtures/app-attest-generator", - "generated_at": "2026-08-02", - "regeneration_command": "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop", "app_id": "TEAMID.xyz.buzz.mobile", "challenge": "buzz-app-attest-strict-verifier-fixture", "aaguid": "appattest", - "leaf_not_after": "Aug 2 05:27:02 2046 GMT", "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdgwggHUMIIBeqADAgECAhEA1hkzMVx4LIlx2Z04+dq+DjAKBggqhkjOPQQDAjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA8MSswKQYDVQQDDCJCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBDcmVkZW50aWFsMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPnUoIjO//gCI6cvjfmAw62OnnngGVoId2q7MQYG//94tXIX2tIZChUe1y/spRzJqxLo0JNm7d9QKdoVuLNBpfaNbMFkwDAYDVR0TAQH/BAIwADAUBgNVHSUEDTALBgkqhkiG92NkBBgwMwYJKoZIhvdjZAgCBCYwJKEiBCDi01h8mHF6AJkdlwJoO7ieXb9TDEttdsV48n1Jd57tIDAKBggqhkjOPQQDAgNIADBFAiEAmyNVz7oG03YWXBP55xcqJ1xrwv7INxQmSKjr/lrrXKwCIEGS9+8qhYxQfZa1q/jcegDlNxphatVVqx5j8cQbjNU2WQG6MIIBtjCCATugAwIBAgIQJj6YcsuecIX6zF/ZFQ6wzDAKBggqhkjOPQQDAzA2MSUwIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowPjEtMCsGA1UEAwwkQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgSW50ZXJtZWRpYXRlMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfGKED5L/Nh0lvKRJAllDU01J6pZhqYBV/a7HRTphUIkIhW0Jc/Q2BplGB+vrMgUG+QX9eG8k7VvRZjov/m7gbaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDaQAwZgIxAONcQ0m5yYfK4ILWwnRWAZjhQg/ZrwiRY3VEBzkAc082FXwp0mqMjXwicSt/ibULFgIxAKFayHKDgusCMjLMPkoIYbOI2jnR+TY8Vftq89b33qLQ2EebRB1PGDld2mvVY01OU2dyZWNlaXB0QGhhdXRoRGF0YVhXH5nFfKMZs8qsLEqZv4n7atEJxvG0oHWjDbycL/O5tJlBAAAAAGFwcGF0dGVzdAAAAAAAAAAAIOtFw/nPMzM0gQAeS/gQ1R2aF7oMMjXIx08QJN8q0cuk", "key_id_b64": "60XD+c8zMzSBAB5L+BDVHZoXugwyNcjHTxAk3yrRy6Q=", "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n" diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json index 4b8a5737b7..56d1260bea 100644 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json @@ -1,12 +1,8 @@ { "description": "Synthetic attestation with a development AAGUID and a correctly recomputed nonce; the strict verifier must report InvalidAAGUID.", - "generator": "crates/buzz-push-gateway/tests/fixtures/app-attest-generator", - "generated_at": "2026-08-02", - "regeneration_command": "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop", "app_id": "TEAMID.xyz.buzz.mobile", "challenge": "buzz-app-attest-strict-verifier-fixture", "aaguid": "appattestdevelop", - "leaf_not_after": "Aug 2 05:27:02 2046 GMT", "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhAXdDyYByLYxE4WftXjOFC1MAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ8ZGvDc7xMJINZw6mLHRU6xr1kFY+vn+PRZYIMypdlYb99U/l8VCK9zWQt+xXSEAyNvzdcZiom5N/fKuAI5xh/o1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEILEfYIC8xsY+hZnqOrQF1PpWR3VioqnjjQwo5/YmtAwRMAoGCCqGSM49BAMCA0gAMEUCIQCpOzhfo94xcJ0ojQki6wxpOdORPsNwXtZz+eByIhtwlwIgPr71d/DiOaQ3Jd9jDaiCFrzozcR5owB0kaKRzvFuBv1ZAbowggG2MIIBO6ADAgECAhAmPphyy55whfrMX9kVDrDMMAoGCCqGSM49BAMDMDYxJTAjBgNVBAMMHEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR8YoQPkv82HSW8pEkCWUNTTUnqlmGpgFX9rsdFOmFQiQiFbQlz9DYGmUYH6+syBQb5Bf14byTtW9FmOi/+buBtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNpADBmAjEA41xDSbnJh8rggtbCdFYBmOFCD9mvCJFjdUQHOQBzTzYVfCnSaoyNfCJxK3+JtQsWAjEAoVrIcoOC6wIyMsw+Sghhs4jaOdH5NjxV+2rz1vfeotDYR5tEHU8YOV3aa9VjTU5TZ3JlY2VpcHRAaGF1dGhEYXRhWFcfmcV8oxmzyqwsSpm/iftq0QnG8bSgdaMNvJwv87m0mUEAAAAAYXBwYXR0ZXN0ZGV2ZWxvcAAg6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=", "key_id_b64": "6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=", "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n" diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json index c709c118e4..7129b63939 100644 --- a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json @@ -1,12 +1,8 @@ { "description": "Internally valid synthetic attestation signed by an unrelated root; the verifier configured with the good fixture root must reject it.", - "generator": "crates/buzz-push-gateway/tests/fixtures/app-attest-generator", - "generated_at": "2026-08-02", - "regeneration_command": "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop", "app_id": "TEAMID.xyz.buzz.mobile", "challenge": "buzz-app-attest-strict-verifier-fixture", "aaguid": "appattest", - "leaf_not_after": "Aug 2 05:27:02 2046 GMT", "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhBGe4kbr8X3vBBmRW24fEPWMAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3NUd9f8Ma88b5fiKPmvgL0akkZfv3Q5v2jJMGVQ+pDY2ZFkZTQnzTfAPydFBFtVQE9HpPLlx22e/8eixSUFdLo1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEIF7PDSiNaYyhbJlVGsubqOBUPUSS4sT5PJ0Ri8mGDRjSMAoGCCqGSM49BAMCA0gAMEUCIQCSjdrbcQurd+avRl+OcRIZPusoJBNVGLun3Rda9tJ5NwIgOFEcGxdOZi3atz7Nwzwe409oVcu4GdXOVo9N86pOu8dZAb8wggG7MIIBQaADAgECAhEAx1cRnQUJhJKCUll92sLeGDAKBggqhkjOPQQDAzA7MSowKAYDVQQDDCFVbnJlbGF0ZWQgQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASz7MX/nc9MaCmjSQ3f+L8SCsgNdFEcDyZ7FxREEPu4bGUujA+P5exSwDuA8L64WrznNITC1J8sZ98VZ/tTNWFtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjEA8ABjGCavBGyl6FgO9u58hV/xzRnhdlFiTUPiN/XCvmfxDkOyYwzLk06/k4JmdqfCAjBADKJsa+9138UAMZgU8iYWTOY+FO96DHsdC+8H9vBoLBE/DxzQHsX2Wd/DEbggUGJncmVjZWlwdEBoYXV0aERhdGFYVx+ZxXyjGbPKrCxKmb+J+2rRCcbxtKB1ow28nC/zubSZQQAAAABhcHBhdHRlc3QAAAAAAAAAACCvcDv+nttQP9RSSwBycpsL+NiE13xuEsfU7iKqeRaTsQ==", "key_id_b64": "r3A7/p7bUD/UUksAcnKbC/jYhNd8bhLH1O4iqnkWk7E=", "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIB1DCCAVugAwIBAgIRALE3l3fzQ4wPjIL/IjBs02IwCgYIKoZIzj0EAwMwOzEq\nMCgGA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYD\nVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowOzEqMCgG\nA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQK\nDARCdXp6MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEPa8SWuDIcjNVDwTXlTQnWbKj\n5Vt8TCiGGH0CiSJajPOlevvjHBEYuVHf7bFYa5N/7OzXQ3qkZomCyizJ6nc5tBEN\nGL3rkz7vZjb9J3QPfixkBwyUHFHmx1WJ84fgAYcDoyMwITAPBgNVHRMBAf8EBTAD\nAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjAMO0cvuHHJSqWj\n4DxJorq8LH7VH9ILTGjcZmz91rLlO7w4oDqiewFQE+GVFl9boekCMBhaa0a/WiW2\nyf2j5d04SOkXREM1NkbHsd1yH1jqSOCuj6PU3Z6zDSSXy1z3HjQIBg==\n-----END CERTIFICATE-----\n" diff --git a/crates/buzz-relay/src/handlers/push_lease.rs b/crates/buzz-relay/src/handlers/push_lease.rs index d3e0188dbb..bf5783d073 100644 --- a/crates/buzz-relay/src/handlers/push_lease.rs +++ b/crates/buzz-relay/src/handlers/push_lease.rs @@ -16,7 +16,6 @@ use sha2::Digest as _; /// Generic Nostr notes and non-message workflow/agent events are deliberately /// excluded from the dogfood MVP. pub(crate) const PUSH_KINDS: &[u64] = &[9, 40_002, 45_001, 45_003]; -pub(crate) const URGENT_KINDS: &[u64] = &[]; /// NIP-PL addressable push-lease event kind. pub const KIND_PUSH_LEASE: u32 = 30_350; @@ -71,7 +70,6 @@ pub struct LeaseLimits<'a> { pub app_profiles: &'a [AppProfile<'a>], pub supported_classes: &'a [&'a str], pub push_kinds: &'a [u64], - pub urgent_kinds: &'a [u64], pub max_subscriptions: usize, pub max_kinds: usize, pub max_authors: usize, @@ -248,14 +246,13 @@ fn validate_subscription(sub: &Subscription, limits: &LeaseLimits<'_>) -> Result if !limits.supported_classes.contains(&sub.class.as_str()) { return Err("class not supported".into()); } - validate_filter(&sub.filter, limits, true, &sub.class)?; + validate_filter(&sub.filter, limits, true)?; if sub.ignore.len() > limits.max_ignore { return Err("ignore quota exceeded".into()); } for filter in &sub.ignore { - // Ignore filters can only subtract from an already-positive match, so - // urgent-kind confinement belongs solely to the positive filter. - validate_filter(filter, limits, false, "")?; + // Ignore filters can only subtract from an already-positive match. + validate_filter(filter, limits, false)?; } if sub.suppress.as_ref().is_some_and(|s| s.p_tags_max == 0) { return Err("p_tags_max must be positive".into()); @@ -267,7 +264,6 @@ fn validate_filter( filter: &Map, limits: &LeaseLimits<'_>, require_narrowing: bool, - class: &str, ) -> Result<(), String> { const ALLOWED: &[&str] = &["kinds", "authors", "#p", "#h", "#e"]; if let Some(key) = filter.keys().find(|key| !ALLOWED.contains(&key.as_str())) { @@ -285,10 +281,6 @@ fn validate_filter( if kinds.iter().any(|kind| !limits.push_kinds.contains(kind)) { return Err("kind not push-eligible".into()); } - if class == "urgent" && kinds.iter().any(|kind| !limits.urgent_kinds.contains(kind)) { - return Err("class not permitted for kind".into()); - } - let authors = optional_string_array(filter, "authors", limits.max_authors)?; let p = optional_string_array(filter, "#p", limits.max_tag_values)?; let h = optional_string_array(filter, "#h", limits.max_h)?; @@ -499,19 +491,12 @@ pub async fn accept( let limits = LeaseLimits { expected_origin: &origin, author_hex: &author_hex, - app_profiles: &[ - AppProfile { - id: "buzz-ios-dogfood", - transport: "apns", - }, - AppProfile { - id: "buzz-ios-app-store", - transport: "apns", - }, - ], - supported_classes: &["silent", "default", "time_sensitive"], + app_profiles: &[AppProfile { + id: "buzz-ios-dogfood", + transport: "apns", + }], + supported_classes: &["default"], push_kinds: PUSH_KINDS, - urgent_kinds: URGENT_KINDS, max_subscriptions: 16, max_kinds: 16, max_authors: 20, @@ -579,14 +564,8 @@ pub async fn accept( .map_err(|_| AcceptError::Internal("lease persistence failed".to_string())) } -fn class_rank(class: &str) -> u8 { - match class { - "silent" => 0, - "default" => 1, - "time_sensitive" => 2, - "urgent" => 3, - _ => 0, - } +fn class_rank(_: &str) -> u8 { + 1 } fn canonical_origin(relay_url: &str, host: &str) -> Result { @@ -687,9 +666,8 @@ mod tests { id: "p", transport: "apns", }], - supported_classes: &["default", "urgent"], - push_kinds: &[9, 46010], - urgent_kinds: &[46010], + supported_classes: &["default"], + push_kinds: &[9], max_subscriptions: 4, max_kinds: 4, max_authors: 4, @@ -766,13 +744,4 @@ mod tests { assert!(canonical_origin("https://relay.example", "tenant.example").is_err()); assert!(canonical_origin("wss://relay.example", "").is_err()); } - - #[test] - fn urgent_is_limited_by_event_kind() { - let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9],"#p":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},"class":"urgent"}]}"##, 4096).unwrap(); - assert_eq!( - validate_plaintext(&body, &limits()).unwrap_err(), - "class not permitted for kind" - ); - } } diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 3a780db787..c2b6c868ab 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -206,14 +206,10 @@ fn push_descriptor( "pubkey": relay_keypair.public_key().to_hex(), "current": true }], - "app_profiles": [ - {"id": "buzz-ios-dogfood", "transport": "apns"}, - {"id": "buzz-ios-app-store", "transport": "apns"} - ], + "app_profiles": [{"id": "buzz-ios-dogfood", "transport": "apns"}], "push_kinds": crate::handlers::push_lease::PUSH_KINDS, - "urgent_kinds": crate::handlers::push_lease::URGENT_KINDS, "h_grammar": "uuid-v4-lowercase", - "class_support": {"apns": ["silent", "default", "time_sensitive"]}, + "class_support": {"apns": ["default"]}, "limitation": { "max_lease_ttl": 2592000, "max_leases_per_pubkey": 16, diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 2b16e05b34..246997aac2 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -682,14 +682,8 @@ fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result u8 { - match class { - "silent" => 0, - "default" => 1, - "time_sensitive" => 2, - "urgent" => 3, - _ => 0, - } +fn class_rank(_: &str) -> u8 { + 1 } #[cfg(test)] diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index 588a394b05..20ce756727 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -31,18 +31,11 @@ spec: - { name: BUZZ_PUSH_HEALTH_ADDR, value: "0.0.0.0:8081" } - { name: BUZZ_PUSH_PUBLIC_DELIVERY_URL, value: {{ .Values.publicDeliveryUrl | quote }} } - { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | quote }} } - - { name: BUZZ_PUSH_ENABLED_PROFILES, value: {{ .Values.enabledProfiles | quote }} } - { name: BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH, value: /run/buzz/app-attest/root.pem } - { name: BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID, value: {{ .Values.profiles.dogfood.appAttestAppId | quote }} } - { name: BUZZ_PUSH_DOGFOOD_APNS_TOPIC, value: {{ .Values.profiles.dogfood.apnsTopic | quote }} } - { name: BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT, value: {{ .Values.profiles.dogfood.apnsEnvironment | quote }} } - { name: BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH, value: /run/buzz/apns-dogfood/identity.pem } - - { name: BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID, value: {{ .Values.profiles.appStore.appAttestAppId | quote }} } - - { name: BUZZ_PUSH_APP_STORE_APNS_TOPIC, value: {{ .Values.profiles.appStore.apnsTopic | quote }} } - - { name: BUZZ_PUSH_APP_STORE_APNS_ENVIRONMENT, value: {{ .Values.profiles.appStore.apnsEnvironment | quote }} } - {{- with .Values.profiles.appStore.apnsCert }} - - { name: BUZZ_PUSH_APP_STORE_APNS_CERT_PATH, value: /run/buzz/apns-app-store/identity.pem } - {{- end }} {{- range $name := list "DATABASE_URL" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }} - name: {{ $name }} valueFrom: { secretKeyRef: { name: {{ $.Values.existingSecret }}, key: {{ $name }} } } @@ -50,9 +43,6 @@ spec: volumeMounts: - { name: app-attest-root, mountPath: /run/buzz/app-attest, readOnly: true } - { name: apns-dogfood, mountPath: /run/buzz/apns-dogfood, readOnly: true } - {{- with .Values.profiles.appStore.apnsCert }} - - { name: apns-app-store, mountPath: /run/buzz/apns-app-store, readOnly: true } - {{- end }} livenessProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 3 } readinessProbe: { httpGet: { path: /_readiness, port: health }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 3 } startupProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 2, failureThreshold: 60 } @@ -62,10 +52,6 @@ spec: secret: { secretName: {{ .Values.appAttestRoot.secretName }}, items: [{ key: {{ .Values.appAttestRoot.secretKey }}, path: root.pem }] } - name: apns-dogfood secret: { secretName: {{ .Values.profiles.dogfood.apnsCert.secretName }}, defaultMode: 0400, items: [{ key: {{ .Values.profiles.dogfood.apnsCert.secretKey }}, path: identity.pem }] } - {{- with .Values.profiles.appStore.apnsCert }} - - name: apns-app-store - secret: { secretName: {{ .secretName }}, defaultMode: 0400, items: [{ key: {{ .secretKey }}, path: identity.pem }] } - {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh index 43a8b360ce..250955c5fc 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -54,11 +54,10 @@ env_names = d.dig("spec", "template", "spec", "containers", 0, "env") required = Set.new(%w[ DATABASE_URL BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH BUZZ_PUSH_DOGFOOD_APNS_TOPIC BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID - BUZZ_PUSH_APP_STORE_APNS_TOPIC BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID BUZZ_PUSH_GRANT_KEYS BUZZ_PUSH_TOKEN_KEYS BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS ]) assert!(required.subset?(env_names)) -assert!(!env_names.include?("BUZZ_PUSH_APP_STORE_APNS_CERT_PATH")) +assert!(!env_names.any? { |name| name.include?("APP_STORE") }) apns_volume = d.dig("spec", "template", "spec", "volumes").find { |volume| volume["name"] == "apns-dogfood" } assert!(apns_volume.dig("secret", "defaultMode") == 0o400, apns_volume.inspect) assert!(d.dig("spec", "replicas") >= 2) diff --git a/deploy/charts/buzz-push-gateway/values.schema.json b/deploy/charts/buzz-push-gateway/values.schema.json index bef5d17a33..29eafa22c8 100644 --- a/deploy/charts/buzz-push-gateway/values.schema.json +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -23,15 +23,11 @@ "type": "object", "additionalProperties": false, "required": [ - "dogfood", - "appStore" + "dogfood" ], "properties": { "dogfood": { "$ref": "#/$defs/enabledProfile" - }, - "appStore": { - "$ref": "#/$defs/dormantProfile" } } }, @@ -286,9 +282,6 @@ } ] }, - "dormantProfile": { - "$ref": "#/$defs/profileBase" - }, "apnsCert": { "type": "object", "additionalProperties": false, diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index 5e5c770623..1f1e90cbb0 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -20,7 +20,6 @@ migration: limits: {cpu: 250m, memory: 128Mi} publicDeliveryUrl: https://push.buzz.xyz/v1/deliveries/apns maxGrantLifetimeSeconds: 2592000 -enabledProfiles: buzz-ios-dogfood profiles: dogfood: # Production MUST override this with the exact Apple TEAMID.bundle-id. @@ -30,12 +29,6 @@ profiles: apnsCert: secretName: buzz-push-gateway secretKey: dogfood-apns-identity.pem - appStore: - # Registered server-side now, but dormant and uncredentialed until its - # profile is explicitly added to enabledProfiles in a later rollout. - appAttestAppId: TEAMID.xyz.block.buzz.mobile - apnsTopic: xyz.block.buzz.mobile - apnsEnvironment: production appAttestRoot: secretName: buzz-push-gateway secretKey: app-attest-root.pem diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index df6428e7cd..bba5fb5c48 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -17,7 +17,7 @@ Push Leases ## Abstract -This NIP defines the **push lease**: a stored, installation-scoped, expiring authorization asking a **push executor** (usually the user's relay) to keep a constrained Nostr filter active after the client's socket closes, and to *wake* a specific application installation through a platform push transport (APNs, FCM, optionally UnifiedPush) when the filter matches. +This NIP defines the **push lease**: a stored, installation-scoped, expiring authorization asking a **push executor** (usually the user's relay) to keep a constrained Nostr filter active after the client's socket closes, and to *wake* a specific application installation through a platform push transport (APNs or FCM) when the filter matches. The push payload is a **wake signal** authored entirely by the configured transport service: a fixed reconnect instruction, never relay-supplied bytes, event ids, event content, URLs, ciphertext, or extensible custom data. On wake, the client reconnects and fetches authoritative events over normal `REQ`. Push delivery is lossy and best-effort — duplicates and omissions are both possible; the relay remains the single source of truth. Platform transports are execution profiles for the lease, not the protocol's content plane. @@ -51,8 +51,8 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d - **origin**: the canonical origin identifier the descriptor advertises for a relay/community; the tenant key (see Acceptance and Origin Binding). - **wake signal**: the fixed, transport-authored reconnect payload defined in Wake Delivery. It contains no relay-supplied application data. - **subscription**: one `{filter, class, ignore?, suppress?}` entry inside a lease. -- **priority class**: one of `silent`, `default`, `time_sensitive`, `urgent`. -- **transport profile**: the APNs/FCM/UnifiedPush-specific execution rules for a lease. +- **priority class**: `default` in this profile. +- **transport profile**: the APNs/FCM-specific execution rules for a lease. ## The Lease Event @@ -73,7 +73,7 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d } ``` -- `d` MUST be generated from at least 128 bits of randomness by the installation, and MUST be distinct per origin — cross-origin unlinkability is a guarantee of this NIP, not a nicety. It MUST NOT contain or be derived from a hardware identifier, advertising identifier, APNs token, FCM registration token, UnifiedPush endpoint, or other transport identifier. Reinstalling the application MUST create a new `d`; transport-token rotation within the same installation MUST retain `d` and replace the existing lease. +- `d` MUST be generated from at least 128 bits of randomness by the installation, and MUST be distinct per origin — cross-origin unlinkability is a guarantee of this NIP, not a nicety. It MUST NOT contain or be derived from a hardware identifier, advertising identifier, APNs token, FCM registration token, or other transport identifier. Reinstalling the application MUST create a new `d`; transport-token rotation within the same installation MUST retain `d` and replace the existing lease. - `expiration` (NIP-40) is REQUIRED and MUST satisfy `now − allowed_skew < expiration ≤ now + max_lease_ttl` at acceptance (`invalid: lease ttl too long` / `invalid: lease already expired`; `max_lease_ttl` descriptor-advertised, default 30 days; RECOMMENDED `allowed_skew` 15 minutes). The executor MUST stop matching once it passes. Inactive (tombstone) replacements carry a public `expiration` under the same bound; it dates the tombstone, not any matching. Expiry is the self-healing backstop for every abuse and leak below. - `exec` names the descriptor encryption key the content was produced for (see Executor Discovery). - Public tags are exactly one `d`, one `expiration`, one `exec`, and at most one `alt`, each with exactly one value; duplicated tags, extra tags, or extra tag values MUST be rejected. The executor MUST reject a lease carrying filter, kind, author, endpoint, or platform data in public tags. @@ -87,12 +87,12 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d "v": 1, "origin": "", // tenant binding, verified — never routed on "app_profile": "com.example.app/ios", // selects transport credentials - "transport": "apns", // "apns" | "fcm" | "unifiedpush" - "endpoint": "", // APNs token / FCM token / UP URL + "transport": "apns", // "apns" | "fcm" + "endpoint": "", // APNs or FCM token/capability "generation": 3, // strictly increasing per lease address "active": true, // false = revocation tombstone "subscriptions": [ - { "filter": { "kinds": [9], "#p": [""] }, "class": "time_sensitive" }, + { "filter": { "kinds": [9], "#p": [""] }, "class": "default" }, { "filter": { "kinds": [9], "#h": [""] }, "class": "default", "ignore": [ { "kinds": [9], "authors": [""], "#h": [""] } ], "suppress": { "p_tags_max": 20 } } @@ -146,16 +146,9 @@ Each subscription carries exactly one `class`: | Class | Meaning | APNs `interruption-level` | Android importance | |---|---|---|---| -| `silent` | Sync-only wake, no alert | not user-visible; see APNs profile | `IMPORTANCE_MIN` | | `default` | Standard notification | `active` | `IMPORTANCE_DEFAULT` | -| `time_sensitive` | Breaks through Focus/DND within OS policy | `time-sensitive` | `IMPORTANCE_HIGH` | -| `urgent` | Reserved: approval gates | `critical` if entitled, else `time-sensitive` | `IMPORTANCE_HIGH` + full-screen intent where policy allows | -Classes are strictly ordered: `silent` < `default` < `time_sensitive` < `urgent`. When one deduplicated wake covers matches from multiple subscriptions or leases targeting the same endpoint (see Coalescing), the wake's effective class is the highest eligible class among those matches. The descriptor's `class_support` is authoritative: a lease naming a class unsupported for its transport MUST be rejected at acceptance (`invalid: class not supported`), never silently downgraded. - -The executor MUST restrict `urgent` to the descriptor-advertised allow-list of approval-request kinds whose eligibility is decidable from the public event envelope (`invalid: class not permitted for kind`). Urgent DMs are explicitly out of scope for v1: gift-wrapped DM content is opaque to the executor, so no privacy-safe urgency marker exists yet; a future revision may add one. - -`silent` remains a matching preference only. The public Buzz APNs profile sends the one fixed reconnect alert and does not expose relay-selected notification classes to the transport boundary. +The descriptor's `class_support` is authoritative: a lease naming an unsupported class MUST be rejected at acceptance (`invalid: class not supported`), never silently downgraded. The public Buzz APNs profile sends the one fixed reconnect alert and does not expose relay-selected notification classes to the transport boundary. Clients MUST NOT register any lease or subscription as a side effect of joining a channel or surface — absent explicit user opt-in the notifiable set is empty. @@ -176,10 +169,8 @@ Until this draft has an upstream NIP number, executors MUST NOT advertise it in "app_profiles": [ { "id": "com.example.app/ios", "transport": "apns" }, { "id": "com.example.app/android", "transport": "fcm" } ], "push_kinds": [9, 1059, 40007, 46010, 7], - "urgent_kinds": [46010], "h_grammar": "uuid-v4-lowercase", - "class_support": { "apns": ["silent","default","time_sensitive","urgent"], - "fcm": ["silent","default","time_sensitive","urgent"] }, + "class_support": { "apns": ["default"], "fcm": ["default"] }, "limitation": { "max_lease_ttl": 2592000, "max_leases_per_pubkey": 16, @@ -192,9 +183,9 @@ Until this draft has an upstream NIP number, executors MUST NOT advertise it in } ``` -A descriptor is valid only if: exactly one key is marked `current` and key ids are unique; app-profile ids are unique; `endpoint` is an `https://` URL; `urgent_kinds ⊆ push_kinds`; and every `class_support` value comes from the class registry in this NIP. Clients MUST treat a descriptor failing these checks as absence of push support. +A descriptor is valid only if: exactly one key is marked `current` and key ids are unique; app-profile ids are unique; `endpoint` is an `https://` URL; and every `class_support` value comes from the class registry in this NIP. Clients MUST treat a descriptor failing these checks as absence of push support. -The executor URL and credentials come from the descriptor, never from the lease. A lease cannot point the executor at an arbitrary HTTP endpoint; this removes the callback-amplification class of attack entirely. Executors MUST NOT dereference a client-supplied `endpoint` URL except as the selected transport profile explicitly defines (UnifiedPush is the only profile whose endpoint is a URL, and it is validated per that profile before use). +The executor URL and credentials come from the descriptor, never from the lease. A lease cannot point the executor at an arbitrary HTTP endpoint; this removes the callback-amplification class of attack entirely. Leases MUST be author-only reads, as specified in Acceptance and Origin Binding, following the NIP-ER access pattern. @@ -230,10 +221,6 @@ The APNs application body is the exact UTF-8 byte constant `{"aps":{"alert":{"bo A future FCM profile MUST define one gateway-owned constant data message with identical noninterference semantics. Until that constant and its wire tests are registered, FCM is not a conforming v1 public-gateway profile. -### UnifiedPush (optional) - -UnifiedPush is not a conforming public-gateway profile in v1 because arbitrary distributor endpoints and message bodies do not meet the fixed-payload authority boundary. A future profile requires a separately registered constant body and hostile-endpoint analysis. - ## Lease and Key Lifecycle A lease is identified by `(author, kind, d)`. A replacement supersedes the prior lease at the same address only by passing the full acceptance sequence, including winning both NIP-01 addressable ordering and the strictly-increasing generation watermark (check 8). Any rejected replacement — stale by either ordering, or invalid for any other reason — MUST leave the stored event, effective push state, and watermark unchanged. @@ -264,15 +251,12 @@ This section registers the public last-hop profile served at `https://push.buzz. ### Registered values and lease mapping -The registered `app_profile` values are `buzz-ios-dogfood` and -`buzz-ios-app-store`. They identify closed Buzz application identities, not APNs -transport environments. The canonical gateway owns the mapping from each -profile to one exact App Attest application identifier, APNs topic, -certificate-backed connection pool, and APNs environment. A client profile -selector chooses only a candidate entry; enrollment succeeds only when App -Attest cryptographically verifies the configured application identifier. A -gateway deployment MUST enable only profiles whose full mapping is configured -consistently, and MUST NOT accept an APNs topic from a client. The APNs token +The registered `app_profile` value is `buzz-ios-dogfood`. It identifies the +closed Buzz dogfood application identity, not an APNs transport environment. +The canonical gateway owns its exact App Attest application identifier, APNs +topic, certificate-backed connection pool, and APNs environment. Enrollment +succeeds only when App Attest cryptographically verifies the configured +application identifier. The gateway MUST NOT accept an APNs topic from a client. The APNs token registered with the gateway is called the **installation endpoint** and never leaves gateway custody after enrollment. @@ -449,6 +433,6 @@ Zombie leases (e.g. `#h` after leaving a channel) are neutralized by match-time - `kind:30350`: push lease (addressable) - `exec` tag: executor encryption-key identifier for `kind:30350` - NIP-11 `supported_extensions`: contains `"nip-pl"` pre-numbering; descriptor object `push` as specified in Executor Discovery -- Classes: `silent`, `default`, `time_sensitive`, `urgent` +- Classes: `default` - `h_grammar` values: `"uuid-v4-lowercase"` (initial entry; origins may register additional grammars with this NIP) -- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profiles `buzz-ios-dogfood`, `buzz-ios-app-store`; wire version `1` +- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profile `buzz-ios-dogfood`; wire version `1` diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 736fdd2bce..a9c4b019d4 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -16,25 +16,20 @@ | `BUZZ_PUSH_PUBLIC_DELIVERY_URL` | Exact externally signed URL, normally `https://push.buzz.xyz/v1/deliveries/apns`. | | `BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS` | Maximum delegation capability lifetime (`1..=31536000`). | | `BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS` | Maximum encrypted-token installation lifetime (default 90 days, max one year). Clients must renew before expiry. | -| `BUZZ_PUSH_ENABLED_PROFILES` | Comma-separated closed application profiles: `buzz-ios-dogfood` and/or `buzz-ios-app-store`. Dogfood is the only enabled MVP profile. | | `BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. | -| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APP_ATTEST_APP_ID` | Exact server-owned Apple App Attest application identifier (`TEAMID.bundle-id`) for each closed profile. | -| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_TOPIC` | Server-owned APNs topic for each profile. Never accepted from a client. | -| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_ENVIRONMENT` | `production` or `sandbox`, selected per profile by deployment configuration. | -| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_CERT_PATH` | Read-only certificate/private-key PEM for an enabled profile. A dormant profile may omit it. | +| `BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID` | Exact server-owned Apple App Attest application identifier (`TEAMID.bundle-id`). | +| `BUZZ_PUSH_DOGFOOD_APNS_TOPIC` | Server-owned APNs topic. Never accepted from a client. | +| `BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT` | `production` or `sandbox`, selected by deployment configuration. | +| `BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH` | Read-only certificate/private-key PEM. | | `BUZZ_PUSH_GRANT_KEYS` | Capability AEAD keyring, `id:base64-32-bytes[,predecessor...]`; current key first. | | `BUZZ_PUSH_TOKEN_KEYS` | Independent token-custody AEAD keyring in the same format. Never reuse grant keys. | -The single canonical `push.buzz.xyz` deployment owns a closed profile registry -for both dogfood (`xyz.block.buzz.dogfood.mobile`) and App Store -(`xyz.block.buzz.mobile`) application identities. Enrollment's profile selector -only chooses a candidate entry: the corresponding App Attest verifier must -cryptographically validate that entry's configured application ID before the -profile is stored with the installation. Assertions and delivery subsequently -select App Attest policy, APNs topic, certificate-backed connection pool, and -environment from that stored profile. No client request or relay grant can -supply or override an APNs topic. The MVP enables and credentials dogfood only; -the App Store entry remains registered but dormant until a later rollout. +The canonical `push.buzz.xyz` MVP serves the dogfood application identity +(`xyz.block.buzz.dogfood.mobile`). App Attest must cryptographically validate +the configured application ID before enrollment. Assertions and delivery use +the server-owned APNs topic, certificate-backed connection pool, and +environment. No client request or relay grant can supply or override an APNs +topic. Optional endpoint quota policy variables are `BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS` (default `10`, max `86400`) and `BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES` (default `10`, max `10000`). These are Buzz policy hypotheses, not Apple-published limits; tune under load while retaining a hard ceiling. @@ -73,7 +68,7 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe | Alert | Fires when | Severity | Action | |---|---|---|---| -| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | The enabled profile's APNs certificate/topic/environment is unhealthy. Check the matching `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_*` configuration. No endpoints are being invalidated. | +| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | The APNs certificate/topic/environment is unhealthy. Check `BUZZ_PUSH_DOGFOOD_APNS_*` configuration. No endpoints are being invalidated. | | `PushGatewayAdmissionUnavailable` | any admission `unavailable` for 5m | critical | PostgreSQL authority store is unreachable. Check DB connectivity and the pod's `postgresEgressCidrs` NetworkPolicy. | | `PushGatewayReadinessAuthorityFailing` | readiness `authority` failures for 5m | warning | Replicas are being pulled from the Service on DB check failure. Fix DB health before capacity drops below the PodDisruptionBudget. | | `PushGatewayReaperFailing` | reaper failed ≥2 times within 30m (runs every 5m) | warning | Expired reservations aren't being swept, growing the bounded-until-expiry window. Check DB write availability. | diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index c222fe30d9..be6ce3b97b 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -121,46 +121,6 @@ private enum BuzzSecureRandom { } } -#if DEBUG - struct BuzzDevAppAttestProvider: BuzzDevAppAttesting { - private static let attestationPrefix = Data("buzz-dev-app-attest-v1:".utf8) - private static let assertionBytes = Data("buzz-dev-app-assertion-v1".utf8) - - let randomBytes: () throws -> Data - - init( - randomBytes: @escaping () throws -> Data = { - try BuzzSecureRandom.bytes(count: 32) - } - ) { - self.randomBytes = randomBytes - } - - func prepareAttestation() async throws -> BuzzDevAttestation { - let entropy = try randomBytes() - precondition(entropy.count == 32, "Development attestation entropy must be exactly 32 bytes") - let bytes = Self.attestationPrefix + entropy - return BuzzDevAttestation( - keyId: Data(SHA256.hash(data: bytes)).base64EncodedString(), - attestation: bytes.base64EncodedString() - ) - } - - func attestation( - _ prepared: BuzzDevAttestation, - clientData: Data - ) async throws -> BuzzDevAttestation { - precondition(!clientData.isEmpty, "Enrollment client data must not be empty") - return prepared - } - - func assertion(clientData: Data) async throws -> String { - precondition(!clientData.isEmpty, "Delegation client data must not be empty") - return Self.assertionBytes.base64EncodedString() - } - } -#endif - private enum BuzzAppAttestKeyId { static func isValid(_ keyId: String) -> Bool { guard !keyId.isEmpty, @@ -374,24 +334,6 @@ public final class BuzzDevPushEnrollmentDriver { ) } - #if DEBUG - public convenience init( - gatewayBaseURL: URL, - store: BuzzPushEndpointGrantStore, - session: URLSession = .shared - ) throws { - try self.init( - gatewayBaseURL: gatewayBaseURL, - store: store, - session: session, - appAttest: BuzzDevAppAttestProvider(), - now: Date.init, - lifetimeSeconds: 2_592_000, - installationIdBytes: { try BuzzSecureRandom.bytes(count: 16) } - ) - } - #endif - init( gatewayBaseURL: URL, store: BuzzPushEndpointGrantStore, diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index f4113292dc..47661e8130 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -83,7 +83,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { let communities = loadCommunities().filter { $0.pubkey?.isEmpty == false && loadPrivateKey($0.id) != nil - && (try? $0.pushSubscriptionState.authoritativeSubscriptions().isEmpty == false) == true + && !$0.policies.isEmpty } guard !communities.isEmpty else { completion(nil) @@ -124,12 +124,11 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { return } guard - let subscriptions = try? community.pushSubscriptionState.authoritativeSubscriptions(), - !subscriptions.isEmpty, + !community.policies.isEmpty, let relayURL = community.relayURL, let url = URL(string: "/query", relativeTo: relayURL), let body = try? JSONSerialization.data( - withJSONObject: subscriptions.map { $0.filter.queryFilter(since: nil, limit: 10) } + withJSONObject: community.policies.map { $0.filter.queryFilter(since: nil, limit: 10) } ) else { completion(nil) @@ -159,8 +158,8 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { let candidate = Self.newestMessage( events: events.filter { event in event.hasValidIDAndSignature() - && subscriptions.contains { subscription in - PushLeaseMatcher.matches(event: event, subscription: subscription) + && community.policies.contains { policy in + PushLeaseMatcher.matches(event: event, policy: policy) } }, community: community diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift index 56eb7703a1..f4a27e8a9f 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -97,15 +97,18 @@ public struct BuzzPushPresentationCacheSnapshot: Codable, Equatable, Sendable { public static let currentVersion = 1 public let version: Int + public var communities: [PushLeaseCommunity] public var profiles: [BuzzPushCachedProfile] public var channels: [BuzzPushCachedChannel] public init( version: Int = currentVersion, + communities: [PushLeaseCommunity] = [], profiles: [BuzzPushCachedProfile] = [], channels: [BuzzPushCachedChannel] = [] ) { self.version = version + self.communities = communities self.profiles = profiles self.channels = channels } @@ -155,10 +158,11 @@ public struct BuzzPushProfileCacheUpdate: Sendable { /// Maintains the bounded presentation snapshot. The app is the sole writer. public final class BuzzPushPresentationCacheStore: @unchecked Sendable { - public static let fileName = "push-presentation-cache.json" + public static let fileName = "push-snapshot.json" public static let freshnessLifetime: TimeInterval = 24 * 60 * 60 public static let maximumProfiles = 256 public static let maximumChannels = 512 + public static let maximumCommunities = 64 public static let maximumMembersPerChannel = 512 public static let maximumTotalMemberDigests = 8_192 public static let maximumAvatarBytes = 64 * 1024 @@ -175,6 +179,19 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { self.now = now } + /// Replaces the app's flattened, relay-accepted community query policy. + public func replaceCommunities(_ communities: [PushLeaseCommunity]) throws { + guard communities.count <= Self.maximumCommunities else { return } + lock.lock() + defer { lock.unlock() } + var snapshot = loadLocked() + snapshot.communities = communities + let retained = Set(communities.map(\.id)) + snapshot.profiles.removeAll { !retained.contains($0.communityID) } + snapshot.channels.removeAll { !retained.contains($0.communityID) } + try writeLocked(snapshot) + } + /// Saves verified kind-0 events and returns the event IDs still needing thumbnails. @discardableResult public func updateProfiles( @@ -416,16 +433,6 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { return true } - /// Removes metadata belonging to communities no longer present in the app. - public func retainCommunities(_ communityIDs: Set) throws { - lock.lock() - defer { lock.unlock() } - var snapshot = loadLocked() - snapshot.profiles.removeAll { !communityIDs.contains($0.communityID) } - snapshot.channels.removeAll { !communityIDs.contains($0.communityID) } - try writeLocked(snapshot) - } - private func loadLocked() -> BuzzPushPresentationCacheSnapshot { guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), let fileSize = values.fileSize, @@ -630,6 +637,7 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { } static func enforceBounds(_ snapshot: inout BuzzPushPresentationCacheSnapshot) { + snapshot.communities = Array(snapshot.communities.prefix(maximumCommunities)) snapshot.profiles = Array( snapshot.profiles.sorted(by: profileNewestFirst).prefix(maximumProfiles) ) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift index 7ac75721f0..cb7eb79108 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift @@ -1,11 +1,5 @@ import Foundation -public enum PushLeaseError: Error, Equatable { - case unsupportedAuthority(String) - case acceptedAuthorityMissingSubscriptions - case emptySubscriptions -} - public struct PushLeaseSnapshot: Codable, Equatable, Sendable { public let communities: [PushLeaseCommunity] @@ -21,7 +15,7 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable { /// Relay NIP-11 `self` key used to verify NIP-29 channel metadata. public let relayMetadataPubkey: String? public let pubkey: String? - public let pushSubscriptionState: PushLeaseSubscriptionState + public let policies: [PushResolutionPolicy] public init( id: String, @@ -29,80 +23,28 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable { relayUrl: String, relayMetadataPubkey: String? = nil, pubkey: String?, - pushSubscriptionState: PushLeaseSubscriptionState + policies: [PushResolutionPolicy] ) { self.id = id self.name = name self.relayUrl = relayUrl self.relayMetadataPubkey = relayMetadataPubkey self.pubkey = pubkey - self.pushSubscriptionState = pushSubscriptionState + self.policies = policies } } -public struct PushLeaseSubscriptionState: Codable, Equatable, Sendable { - public enum Authority: String, Codable, Sendable { - case desired - case accepted - } - - public let authority: String - public let desired: [PushLeaseSubscription] - public let accepted: [PushLeaseSubscription]? - - public init( - authority: String, - desired: [PushLeaseSubscription], - accepted: [PushLeaseSubscription]? = nil - ) { - self.authority = authority - self.desired = desired - self.accepted = accepted - } - - /// The app persists accepted authority only after the relay acknowledges the - /// corresponding lease. Until then the desired policy is used for snapshots. - public func authoritativeSubscriptions() throws -> [PushLeaseSubscription] { - let subscriptions: [PushLeaseSubscription] - switch authority { - case Authority.desired.rawValue: - subscriptions = desired - case Authority.accepted.rawValue: - guard let accepted else { - throw PushLeaseError.acceptedAuthorityMissingSubscriptions - } - subscriptions = accepted - default: - throw PushLeaseError.unsupportedAuthority(authority) - } - guard !subscriptions.isEmpty else { - throw PushLeaseError.emptySubscriptions - } - return subscriptions - } -} - -public struct PushLeaseSubscription: Codable, Equatable, Sendable { +public struct PushResolutionPolicy: Codable, Equatable, Sendable { public let filter: PushLeaseFilter - public let notificationClass: String public let ignore: [PushLeaseFilter] public let suppress: PushLeaseSuppression? - enum CodingKeys: String, CodingKey { - case filter - case notificationClass = "class" - case ignore - case suppress - } - public init( filter: PushLeaseFilter, - notificationClass: String, ignore: [PushLeaseFilter] = [], suppress: PushLeaseSuppression? = nil ) { self.filter = filter - self.notificationClass = notificationClass self.ignore = ignore self.suppress = suppress } @@ -172,11 +114,11 @@ public struct PushLeaseFilter: Codable, Equatable, Sendable { public enum PushLeaseMatcher { public static func matches( event: VerifiedNostrEvent, - subscription: PushLeaseSubscription + policy: PushResolutionPolicy ) -> Bool { - guard subscription.filter.matches(event) else { return false } - if subscription.ignore.contains(where: { $0.matches(event) }) { return false } - if let maximum = subscription.suppress?.pTagsMax, + guard policy.filter.matches(event) else { return false } + if policy.ignore.contains(where: { $0.matches(event) }) { return false } + if let maximum = policy.suppress?.pTagsMax, event.tagCount(named: "p") > maximum { return false diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 821cd24002..5e19fa7661 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -215,25 +215,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443") } - #if DEBUG - func testDevelopmentAttestationMatchesGatewayBypassShape() async throws { - let entropy = Data(repeating: 0xAB, count: 32) - let provider = BuzzDevAppAttestProvider(randomBytes: { entropy }) - let prepared = try await provider.prepareAttestation() - let bytes = try XCTUnwrap(Data(base64Encoded: prepared.attestation)) - XCTAssertEqual( - bytes, - Data("buzz-dev-app-attest-v1:".utf8) + entropy - ) - XCTAssertEqual( - prepared.keyId, - Data(SHA256.hash(data: bytes)).base64EncodedString() - ) - let assertion = try await provider.assertion(clientData: Data("transcript".utf8)) - XCTAssertEqual(assertion, Self.assertion) - } - #endif - func testLegacyGrantDecodesWithoutMetadataAuthority() throws { let data = Data( #"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"#.utf8 diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index 907795d3f3..b25e23595f 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -717,19 +717,14 @@ final class BuzzPushNotificationResolverTests: XCTestCase { relayUrl: relayUrl, relayMetadataPubkey: relayMetadataPubkey, pubkey: pubkey, - pushSubscriptionState: PushLeaseSubscriptionState( - authority: "accepted", - desired: [], - accepted: [ - PushLeaseSubscription( + policies: [ + PushResolutionPolicy( filter: PushLeaseFilter( kinds: [9, 40002, 45001, 45003], hTags: [Self.channelID] - ), - notificationClass: "default" + ) ) ] - ) ) } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift index 87187f37c5..ed592c576f 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -512,20 +512,6 @@ struct BuzzPushPresentationCacheTests { #expect(cached.membershipEventID == membership.id) } - @Test("Legacy cache decodes with channel membership unavailable") - func legacyCacheCompatibility() throws { - let legacy = try #require( - #"{"version":1,"profiles":[],"channels":[{"communityID":"community-a","relayOrigin":"https://relay.example","channelID":"opaque","relayMetadataPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","displayName":"General","eventID":"event","eventCreatedAt":1,"cachedAt":2}]}"# - .data(using: .utf8) - ) - - let channel = try #require(BuzzPushPresentationCacheSnapshot.decode(legacy).channels.first) - - #expect(channel.displayName == "General") - #expect(channel.memberCount == nil) - #expect(channel.memberDigests == nil) - } - @Test("Missing or malformed channel metadata never fabricates a name") func malformedChannelMetadataFallback() throws { let directory = try temporaryDirectory() @@ -589,9 +575,18 @@ struct BuzzPushPresentationCacheTests { membershipEvents: [] ) - try store.retainCommunities(["retained"]) + try store.replaceCommunities([ + PushLeaseCommunity( + id: "retained", + name: "Retained", + relayUrl: "https://relay.example", + pubkey: nil, + policies: [] + ) + ]) let snapshot = try loadSnapshot(directory) + #expect(snapshot.communities.map(\.id) == ["retained"]) #expect(snapshot.profiles.isEmpty) #expect(snapshot.channels.isEmpty) } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift index 3c8190d393..096737d535 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift @@ -6,28 +6,6 @@ final class PushLeaseTests: XCTestCase { private let mine = String(repeating: "a", count: 64) private let other = String(repeating: "b", count: 64) - func testDesiredAuthorityIsExplicitAndAcceptedAuthorityRequiresState() throws { - let subscription = PushLeaseSubscription( - filter: PushLeaseFilter(kinds: [9], pTags: [mine]), - notificationClass: "default" - ) - XCTAssertEqual( - try PushLeaseSubscriptionState( - authority: "desired", - desired: [subscription] - ).authoritativeSubscriptions(), - [subscription] - ) - XCTAssertThrowsError( - try PushLeaseSubscriptionState( - authority: "accepted", - desired: [subscription] - ).authoritativeSubscriptions() - ) { error in - XCTAssertEqual(error as? PushLeaseError, .acceptedAuthorityMissingSubscriptions) - } - } - func testFilterBuildsQueryFromLeaseWithoutHardcodedKinds() { let filter = PushLeaseFilter( kinds: [7, 1059], @@ -47,46 +25,43 @@ final class PushLeaseTests: XCTestCase { func testPushEligibleKindAbsentFromOldConstantMatchesLease() { let event = makeEvent(kind: 1059, tags: [["p", mine]]) - let subscription = PushLeaseSubscription( - filter: PushLeaseFilter(kinds: [1059], pTags: [mine]), - notificationClass: "default" + let policy = PushResolutionPolicy( + filter: PushLeaseFilter(kinds: [1059], pTags: [mine]) ) - XCTAssertTrue(PushLeaseMatcher.matches(event: event, subscription: subscription)) + XCTAssertTrue(PushLeaseMatcher.matches(event: event, policy: policy)) } func testIgnoreAndHellthreadSuppressionRejectCandidates() { let ignored = makeEvent(kind: 9, pubkey: other, tags: [["p", mine]]) - let ignoreSubscription = PushLeaseSubscription( + let ignorePolicy = PushResolutionPolicy( filter: PushLeaseFilter(kinds: [9], pTags: [mine]), - notificationClass: "default", ignore: [PushLeaseFilter(kinds: [9], authors: [other])] ) XCTAssertFalse( - PushLeaseMatcher.matches(event: ignored, subscription: ignoreSubscription) + PushLeaseMatcher.matches(event: ignored, policy: ignorePolicy) ) let hellthread = makeEvent( kind: 9, tags: (0..<21).map { ["p", String(format: "%064x", $0)] } ) - let suppressed = PushLeaseSubscription( + let suppressed = PushResolutionPolicy( filter: PushLeaseFilter(kinds: [9], authors: [other]), - notificationClass: "default", suppress: PushLeaseSuppression(pTagsMax: 20) ) - XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, subscription: suppressed)) + XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, policy: suppressed)) } func testDecodesSnapshotContractFromDartShape() throws { let json = """ - {"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","pushSubscriptionState":{"authority":"desired","desired":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"class":"default","ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}}]} + {"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","policies":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}]} """ let snapshot = try JSONDecoder().decode(PushLeaseSnapshot.self, from: Data(json.utf8)) XCTAssertEqual(snapshot.communities.count, 1) XCTAssertEqual( - try snapshot.communities[0].pushSubscriptionState.authoritativeSubscriptions().count, + snapshot.communities[0].policies.count, 1 ) } diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index 644677c04f..44965720a9 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -19,7 +19,7 @@ final class NotificationService: UNNotificationServiceExtension { return BuzzPushNotificationResolver( session: .shared, loadCommunitiesData: { - Self.loadCommunitiesData(appGroupIdentifier: appGroupIdentifier) + Self.loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier) }, loadPrivateKey: { communityID in Self.loadPrivateKey( @@ -28,11 +28,7 @@ final class NotificationService: UNNotificationServiceExtension { ) }, loadPresentationCacheData: { - Self.loadAppGroupData( - fileName: BuzzPushPresentationCacheStore.fileName, - appGroupIdentifier: appGroupIdentifier, - maximumBytes: BuzzPushPresentationCacheStore.maximumSnapshotBytes - ) + Self.loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier) } ) }() @@ -113,10 +109,11 @@ final class NotificationService: UNNotificationServiceExtension { return String(data: data, encoding: .utf8) } - private static func loadCommunitiesData(appGroupIdentifier: String?) -> Data? { + private static func loadPushSnapshotData(appGroupIdentifier: String?) -> Data? { loadAppGroupData( - fileName: "push-communities.json", - appGroupIdentifier: appGroupIdentifier + fileName: BuzzPushPresentationCacheStore.fileName, + appGroupIdentifier: appGroupIdentifier, + maximumBytes: BuzzPushPresentationCacheStore.maximumSnapshotBytes ) } diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 023dade3fd..29ce66de69 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -36,7 +36,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; BZZ00000000000000000023 /* PushNativeState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000024 /* PushNativeState.swift */; }; BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000027 /* PushEndpointGrantStore.swift */; }; - BZZ00000000000000000029 /* PushPresentationCacheBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002A /* PushPresentationCacheBridge.swift */; }; + BZZ00000000000000000029 /* PushSnapshotBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002A /* PushSnapshotBridge.swift */; }; BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -114,7 +114,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; BZZ00000000000000000024 /* PushNativeState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNativeState.swift; sourceTree = ""; }; BZZ00000000000000000027 /* PushEndpointGrantStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEndpointGrantStore.swift; sourceTree = ""; }; - BZZ0000000000000000002A /* PushPresentationCacheBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPresentationCacheBridge.swift; sourceTree = ""; }; + BZZ0000000000000000002A /* PushSnapshotBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSnapshotBridge.swift; sourceTree = ""; }; BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuzzCommunicationNotificationTests.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -240,7 +240,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, BZZ00000000000000000024 /* PushNativeState.swift */, BZZ00000000000000000027 /* PushEndpointGrantStore.swift */, - BZZ0000000000000000002A /* PushPresentationCacheBridge.swift */, + BZZ0000000000000000002A /* PushSnapshotBridge.swift */, 331C809A294A618700263BE5 /* MediaSanitizer.swift */, 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */, 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */, @@ -563,7 +563,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, BZZ00000000000000000023 /* PushNativeState.swift in Sources */, BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */, - BZZ00000000000000000029 /* PushPresentationCacheBridge.swift in Sources */, + BZZ00000000000000000029 /* PushSnapshotBridge.swift in Sources */, 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */, 4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */, 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 1da32bca4e..b3f8933399 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -19,8 +19,13 @@ import os.log private var appGroupIdentifier: String? { Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String } - private lazy var pushPresentationCacheBridge = BuzzPushPresentationCacheBridge( - appGroupIdentifier: appGroupIdentifier + private var pushKeychainAccessGroup: String? { + Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + } + private lazy var pushSnapshotBridge = BuzzPushSnapshotBridge( + appGroupIdentifier: appGroupIdentifier, + endpointGrantStore: endpointGrantStore, + keychainAccessGroup: pushKeychainAccessGroup ) private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? @@ -326,7 +331,7 @@ import os.log _ call: FlutterMethodCall, result: @escaping FlutterResult ) { - if pushPresentationCacheBridge.handle(call, result: result) { + if pushSnapshotBridge.handle(call, result: result) { return } switch call.method { @@ -334,30 +339,6 @@ import os.log startPushRegistration(result: result) case "takePendingNotificationResponse": result(pushNavigationBuffer.take()?.flutterArguments) - case "saveCommunitySnapshot": - guard let arguments = call.arguments as? [String: Any], - let communities = arguments["communities"] as? [[String: Any]], - let signingKeys = arguments["signingKeys"] as? [String: String] - else { - result( - FlutterError( - code: "invalid_arguments", message: "Expected communities array.", details: nil)) - return - } - do { - try savePushCommunitySnapshot(communities) - try BuzzPushKeychain.replace( - signingKeys: signingKeys, - accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") - as? String - ) - result(nil) - } catch { - result( - FlutterError( - code: "save_failed", message: "Unable to save push community credentials.", - details: error.localizedDescription)) - } case "endpointGrants": do { result(try endpointGrantStore.records().map(\.flutterArguments)) @@ -445,21 +426,13 @@ import os.log } do { - let driver: BuzzDevPushEnrollmentDriver - #if DEBUG - driver = try BuzzDevPushEnrollmentDriver( - gatewayBaseURL: gatewayURL, - store: endpointGrantStore - ) - #else - driver = try BuzzDevPushEnrollmentDriver( - gatewayBaseURL: gatewayURL, - store: endpointGrantStore, - appAttestKeychainAccessGroup: Bundle.main.object( - forInfoDictionaryKey: "BuzzKeychainAccessGroup" - ) as? String - ) - #endif + let driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore, + appAttestKeychainAccessGroup: Bundle.main.object( + forInfoDictionaryKey: "BuzzKeychainAccessGroup" + ) as? String + ) enrollmentTask = Task { [weak self] in defer { self?.enrollmentTask = nil } do { @@ -491,58 +464,6 @@ import os.log } } - private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws { - guard let appGroupIdentifier else { - throw NSError( - domain: "BuzzPush", code: 1, - userInfo: [NSLocalizedDescriptionKey: "Missing BuzzAppGroupIdentifier"]) - } - guard - let container = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupIdentifier) - else { - throw NSError( - domain: "BuzzPush", code: 2, - userInfo: [NSLocalizedDescriptionKey: "Missing App Group container"]) - } - // Channel-name enrichment is optional presentation state. A damaged or - // unavailable grant cache must not block the core NSE snapshot/key update. - let grants = (try? endpointGrantStore.records()) ?? [] - let enriched = communities.map { community -> [String: Any] in - var community = community - guard let relayURL = community["relayUrl"] as? String, - let relayMetadataPubkey = Self.pushRelayMetadataPubkey( - relayURL: relayURL, - grants: grants - ) - else { return community } - community["relayMetadataPubkey"] = relayMetadataPubkey - return community - } - let data = try JSONSerialization.data( - withJSONObject: ["communities": enriched], options: [.sortedKeys]) - let destination = container.appendingPathComponent("push-communities.json") - try data.write(to: destination, options: [.atomic]) - pushPresentationCacheBridge.retainCommunities( - Set(enriched.compactMap { $0["id"] as? String }) - ) - } - - static func pushRelayMetadataPubkey( - relayURL: String, - grants: [BuzzPushEndpointGrantRecord] - ) -> String? { - guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else { - return nil - } - return grants.filter { - $0.appProfile == BuzzDevPushEnrollmentDriver.appProfile - && BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin - }.max { - $0.generation < $1.generation - }?.relayMetadataPubkey - } - private func handleMediaUploadMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult diff --git a/mobile/ios/Runner/PushPresentationCacheBridge.swift b/mobile/ios/Runner/PushSnapshotBridge.swift similarity index 61% rename from mobile/ios/Runner/PushPresentationCacheBridge.swift rename to mobile/ios/Runner/PushSnapshotBridge.swift index 51894fcc79..5c7bfb5fac 100644 --- a/mobile/ios/Runner/PushPresentationCacheBridge.swift +++ b/mobile/ios/Runner/PushSnapshotBridge.swift @@ -2,10 +2,12 @@ import BuzzPushKit import Flutter import Foundation -final class BuzzPushPresentationCacheBridge { +final class BuzzPushSnapshotBridge { private let appGroupIdentifier: String? + private let endpointGrantStore: BuzzPushEndpointGrantKeychainStore + private let keychainAccessGroup: String? private let queue = DispatchQueue( - label: "xyz.block.buzz.push-presentation-cache", + label: "xyz.block.buzz.push-snapshot", qos: .utility ) private lazy var store: BuzzPushPresentationCacheStore? = { @@ -17,29 +19,102 @@ final class BuzzPushPresentationCacheBridge { return BuzzPushPresentationCacheStore(containerURL: container) }() - init(appGroupIdentifier: String?) { + init( + appGroupIdentifier: String?, + endpointGrantStore: BuzzPushEndpointGrantKeychainStore, + keychainAccessGroup: String? + ) { self.appGroupIdentifier = appGroupIdentifier + self.endpointGrantStore = endpointGrantStore + self.keychainAccessGroup = keychainAccessGroup } @discardableResult func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) -> Bool { - switch call.method { - case "cachePresentationProfiles": - cacheProfiles(call.arguments, result: result) - case "cachePresentationChannels": - cacheChannels(call.arguments, result: result) - case "cachePresentationAvatar": - cacheAvatar(call.arguments, result: result) - default: + guard call.method == "syncPushSnapshot", + let arguments = call.arguments as? [String: Any], + let section = arguments["section"] as? String + else { return false } + switch section { + case "communities": syncCommunities(arguments, result: result) + case "profiles": cacheProfiles(arguments, result: result) + case "channels": cacheChannels(arguments, result: result) + case "avatar": cacheAvatar(arguments, result: result) + default: return false + } return true } - func retainCommunities(_ communityIDs: Set) { - queue.async { [weak self] in - try? self?.store?.retainCommunities(communityIDs) + private func syncCommunities(_ arguments: [String: Any], result: @escaping FlutterResult) { + guard let communities = arguments["communities"] as? [[String: Any]], + let signingKeys = arguments["signingKeys"] as? [String: String], + communities.count <= BuzzPushPresentationCacheStore.maximumCommunities + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected bounded communities and signing keys.", + details: nil + ) + ) + return } + queue.async { [weak self] in + do { + guard let self, let store else { + Self.complete(result, value: nil) + return + } + // Relay-metadata enrichment is optional presentation state. A damaged + // grant cache must not block the core NSE snapshot and key update. + let grants = (try? endpointGrantStore.records()) ?? [] + let enriched = communities.map { community -> [String: Any] in + var community = community + guard let relayURL = community["relayUrl"] as? String, + let relayMetadataPubkey = Self.relayMetadataPubkey( + relayURL: relayURL, + grants: grants + ) + else { return community } + community["relayMetadataPubkey"] = relayMetadataPubkey + return community + } + let data = try JSONSerialization.data(withJSONObject: enriched, options: [.sortedKeys]) + let decoded = try JSONDecoder().decode([PushLeaseCommunity].self, from: data) + try store.replaceCommunities(decoded) + try BuzzPushKeychain.replace( + signingKeys: signingKeys, + accessGroup: keychainAccessGroup + ) + Self.complete(result, value: nil) + } catch { + Self.complete( + result, + value: FlutterError( + code: "snapshot_sync_failed", + message: "Unable to sync push community state.", + details: error.localizedDescription + ) + ) + } + } + } + + static func relayMetadataPubkey( + relayURL: String, + grants: [BuzzPushEndpointGrantRecord] + ) -> String? { + guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else { + return nil + } + return grants.filter { + $0.appProfile == BuzzDevPushEnrollmentDriver.appProfile + && BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin + }.max { + $0.generation < $1.generation + }?.relayMetadataPubkey } private func cacheProfiles(_ rawArguments: Any?, result: @escaping FlutterResult) { @@ -177,10 +252,8 @@ final class BuzzPushPresentationCacheBridge { let container = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupIdentifier ), - let data = try? Data( - contentsOf: container.appendingPathComponent("push-communities.json") - ), - let snapshot = try? JSONDecoder().decode(PushLeaseSnapshot.self, from: data) + let data = try? Data(contentsOf: container.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)), + let snapshot = try? JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.self, from: data) else { return nil } return snapshot.communities.first { $0.id == id } } diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index 8d8f1373df..156aa61521 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -182,13 +182,13 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { metadataPubkey: String(repeating: "a", count: 64) ) let wrongProfile = grant( - appProfile: "buzz-ios-app-store", + appProfile: "other-profile", generation: 99, metadataPubkey: String(repeating: "b", count: 64) ) XCTAssertEqual( - AppDelegate.pushRelayMetadataPubkey( + BuzzPushSnapshotBridge.relayMetadataPubkey( relayURL: "wss://relay.example/", grants: [wrongProfile, correctProfile] ), diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 3d2061ff13..9d3c0fdc8a 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -5,8 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/deeplink/pending_deep_link_provider.dart'; -import '../../shared/community/community.dart'; -import '../../shared/community/community_provider.dart'; import '../invites/invite_join_provider.dart'; import '../invites/invite_join_sheet.dart'; import 'channel.dart'; @@ -41,7 +39,6 @@ class DeepLinkDispatcher extends ConsumerStatefulWidget { class _DeepLinkDispatcherState extends ConsumerState { bool _preparingInvite = false; - String? _switchingCommunityId; @override void initState() { @@ -62,12 +59,6 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.listen>>(channelsProvider, (_, _) { _maybeDispatch(ref.read(pendingDeepLinkProvider)); }); - ref.listen>(activeCommunityProvider, (_, _) { - _maybeDispatch(ref.read(pendingDeepLinkProvider)); - }); - ref.listen>>(communityListProvider, (_, _) { - _maybeDispatch(ref.read(pendingDeepLinkProvider)); - }); } return widget.child; @@ -83,8 +74,43 @@ class _DeepLinkDispatcherState extends ConsumerState { !widget.dispatchMessageLinks) { return; } - if (link is MessageDeepLink && !_prepareNotificationCommunity(link)) return; + if (link is MessageDeepLink) { + unawaited(_dispatchNotificationLink(link)); + return; + } + _dispatchNavigableLink(link); + } + + Future _dispatchNotificationLink(MessageDeepLink link) async { + final preparation = await ref + .read(pendingDeepLinkProvider.notifier) + .prepareCommunity(link); + if (!mounted || ref.read(pendingDeepLinkProvider) != link) return; + switch (preparation) { + case DeepLinkCommunityPreparation.ready: + _dispatchNavigableLink(link); + case DeepLinkCommunityPreparation.switched: + // The community-scoped app subtree remounts and consumes the parked + // target after its channels load. + return; + case DeepLinkCommunityPreparation.unavailable: + ref.read(pendingDeepLinkProvider.notifier).consume(); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Notification community is no longer available'), + ), + ); + case DeepLinkCommunityPreparation.failed: + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Could not open the notification community'), + ), + ); + } + } + + void _dispatchNavigableLink(BuzzDeepLink link) { final channelId = switch (link) { MessageDeepLink(:final channelId) => channelId, ChannelDeepLink(:final channelId) => channelId, @@ -131,59 +157,6 @@ class _DeepLinkDispatcherState extends ConsumerState { ); } - bool _prepareNotificationCommunity(MessageDeepLink link) { - final communityId = link.communityId; - if (communityId == null) return true; - - final communities = ref.read(communityListProvider).asData?.value; - if (communities == null) return false; - if (!communities.any((community) => community.id == communityId)) { - ref.read(pendingDeepLinkProvider.notifier).consume(); - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar( - content: Text('Notification community is no longer available'), - ), - ); - return false; - } - - final activeCommunity = ref.read(activeCommunityProvider).asData?.value; - if (activeCommunity?.id == communityId) return true; - _switchCommunity(communityId); - return false; - } - - void _switchCommunity(String communityId) { - if (_switchingCommunityId != null) return; - _switchingCommunityId = communityId; - Future.microtask(() async { - var switched = false; - try { - await ref - .read(communityListProvider.notifier) - .switchCommunity(communityId); - switched = true; - } catch (error) { - debugPrint( - 'notification-routing: failed to switch to community ' - '$communityId: $error', - ); - if (mounted) { - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - const SnackBar( - content: Text('Could not open the notification community'), - ), - ); - } - } finally { - _switchingCommunityId = null; - if (mounted && switched) { - _maybeDispatch(ref.read(pendingDeepLinkProvider)); - } - } - }); - } - void _maybeDispatchInvite(InviteDeepLink link) { if (_preparingInvite) return; _preparingInvite = true; diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 76a73a20f9..d853e60d4e 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -101,18 +101,23 @@ final communityPushLeaseDeactivatorProvider = Future _deactivateCommunityPushLease(Community community) async { final state = community.pushSubscriptionState; final acceptedGeneration = state.acceptedGeneration; - final installationId = state.acceptedInstallationId; final nsec = community.nsec; - if (acceptedGeneration == null || - installationId == null || - nsec == null || - nsec.isEmpty) { + if (acceptedGeneration == null || nsec == null || nsec.isEmpty) { return; } try { final decoded = nostr.Nip19.decode(payload: nsec); final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public; final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl); + final installationId = (await readBuzzPushEndpointGrants()) + .where( + (grant) => + grant.relayOrigin == descriptor.origin && + grant.appProfile == buzzDevPushAppProfile, + ) + .map((grant) => grant.installationId) + .firstOrNull; + if (installationId == null) return; final uri = Uri.parse(community.relayUrl); final httpScheme = switch (uri.scheme) { 'wss' => 'https', @@ -320,8 +325,6 @@ class CommunityListNotifier extends AsyncNotifier> { String id, { required List subscriptions, required int generation, - required int grantGeneration, - required String installationId, }) async { final storage = ref.read(communityStorageProvider); final current = state.value ?? await storage.loadAll(); @@ -333,8 +336,6 @@ class CommunityListNotifier extends AsyncNotifier> { pushSubscriptionState: community.pushSubscriptionState.withAccepted( subscriptions: subscriptions, generation: generation, - grantGeneration: grantGeneration, - installationId: installationId, ), ); await storage.save(updated); diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart index 0f8c052c28..70f1a6a3b6 100644 --- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart +++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart @@ -6,8 +6,11 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'deep_link.dart'; +import '../community/community_provider.dart'; import '../push/push_bridge.dart'; +enum DeepLinkCommunityPreparation { ready, switched, unavailable, failed } + /// Holds supported deep links until they can be dispatched. /// /// Links are queued in arrival order. Navigation cannot always happen the @@ -64,6 +67,35 @@ class PendingDeepLinkNotifier extends Notifier { state = _waiting.isEmpty ? null : _waiting.removeFirst(); } + /// Selects the device-local community carried by a structured push target. + /// Ordinary shared deep links have no community ID and remain unchanged. + Future prepareCommunity( + BuzzDeepLink link, + ) async { + if (link is! MessageDeepLink || link.communityId == null) { + return DeepLinkCommunityPreparation.ready; + } + final communityId = link.communityId!; + try { + final communities = await ref.read(communityListProvider.future); + if (!communities.any((community) => community.id == communityId)) { + return DeepLinkCommunityPreparation.unavailable; + } + final active = await ref.read(activeCommunityProvider.future); + if (active?.id == communityId) return DeepLinkCommunityPreparation.ready; + await ref + .read(communityListProvider.notifier) + .switchCommunity(communityId); + return DeepLinkCommunityPreparation.switched; + } catch (error) { + debugPrint( + 'notification-routing: failed to switch to community ' + '$communityId: $error', + ); + return DeepLinkCommunityPreparation.failed; + } + } + void _enqueue(BuzzDeepLink link) { if (state == null) { state = link; diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index 9108c104b0..a999ae2903 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -87,7 +87,6 @@ class BuzzPushLeaseDescriptor { 'keys', 'app_profiles', 'push_kinds', - 'urgent_kinds', 'h_grammar', 'class_support', 'limitation', @@ -97,7 +96,6 @@ class BuzzPushLeaseDescriptor { 'keys', 'app_profiles', 'push_kinds', - 'urgent_kinds', 'h_grammar', 'class_support', 'limitation', @@ -170,16 +168,6 @@ class BuzzPushLeaseDescriptor { 'NIP-11 does not advertise every Buzz message kind for push', ); } - final urgentKinds = _intList( - push['urgent_kinds'], - name: 'urgent_kinds', - allowEmpty: true, - ); - if (urgentKinds.any((kind) => !pushKinds.contains(kind))) { - throw const FormatException( - 'urgent_kinds must be a subset of push_kinds', - ); - } final hGrammar = _nonEmptyString(push['h_grammar'], name: 'h_grammar'); if (hGrammar != 'uuid-v4-lowercase') { throw const FormatException('Unsupported push h_grammar'); @@ -193,7 +181,7 @@ class BuzzPushLeaseDescriptor { classSupport[buzzPushTransport], name: 'class_support.apns', ); - const knownClasses = {'silent', 'default', 'time_sensitive', 'urgent'}; + const knownClasses = {'default'}; if (supportedClasses.any((value) => !knownClasses.contains(value))) { throw const FormatException('class_support contains an unknown class'); } diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 0652ad8fa4..ce4efb2967 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -68,21 +68,11 @@ class BuzzPushBootstrap extends HookConsumerWidget { } final state = community!.pushSubscriptionState; if (state.desired.isEmpty) return null; - final desiredFingerprint = buzzPushSubscriptionsFingerprint( - state.desired, - ); - final acceptedFingerprint = state.accepted == null - ? '-' - : buzzPushSubscriptionsFingerprint(state.accepted!); final attempt = [ community.id, config.baseUrl, token, - desiredFingerprint, - acceptedFingerprint, - state.acceptedGeneration, - state.acceptedGrantGeneration, - state.acceptedInstallationId, + buzzPushSubscriptionsFingerprint(state.desired), ].join('|'); if (publicationAttempt.value == attempt) return null; publicationAttempt.value = attempt; @@ -138,10 +128,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { ) async { final state = community.pushSubscriptionState; final desired = state.desired; - final desiredFingerprint = buzzPushSubscriptionsFingerprint(desired); - final acceptedFingerprint = state.accepted == null - ? null - : buzzPushSubscriptionsFingerprint(state.accepted!); final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); final grant = await enrollBuzzPush( config.wsUrl, @@ -149,12 +135,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { communitiesForSnapshotRefresh: ref.read(communityListProvider).value ?? [community], ); - if (state.authority == BuzzPushLeaseSubscriptionAuthority.accepted && - acceptedFingerprint == desiredFingerprint && - state.acceptedGrantGeneration == grant.generation && - state.acceptedInstallationId == grant.installationId) { - return; - } // Relay lease replacement and gateway delegation are independent state // machines. Subscription changes advance only the kind-30350 generation; // the opaque grant remains reusable until its own authority changes. @@ -175,8 +155,6 @@ class BuzzPushBootstrap extends HookConsumerWidget { community.id, subscriptions: desired, generation: leaseGeneration, - grantGeneration: grant.generation, - installationId: grant.installationId, ); } } diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index db647aefd9..513971fcf6 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -187,7 +187,7 @@ Future registerBuzzPushCommunitySnapshot( name: community.name, relayUrl: community.relayUrl, pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec), - pushSubscriptionState: community.pushSubscriptionState, + subscriptions: community.pushSubscriptionState.authoritative, ), ]; final signingKeys = {}; @@ -205,7 +205,8 @@ Future registerBuzzPushCommunitySnapshot( // Native storage is fail-closed; malformed keys are never exported. } } - await _channel.invokeMethod('saveCommunitySnapshot', { + await _channel.invokeMethod('syncPushSnapshot', { + 'section': 'communities', 'communities': [for (final snapshot in snapshots) snapshot.toJson()], 'signingKeys': signingKeys, }); diff --git a/mobile/lib/shared/push/push_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart index 50c1795da4..82cc78a89d 100644 --- a/mobile/lib/shared/push/push_presentation_cache.dart +++ b/mobile/lib/shared/push/push_presentation_cache.dart @@ -8,9 +8,6 @@ import 'package:nostr/nostr.dart' as nostr; import '../relay/nostr_models.dart'; const _pushPresentationChannel = MethodChannel('buzz/push'); -// Keep these bridge payload bounds aligned with BuzzPushPresentationCacheStore. -const _maximumPresentationProfiles = 256; -const _maximumPresentationChannels = 512; const _maximumAvatarSourceBytes = 512 * 1024; const _maximumAvatarPNGBytes = 64 * 1024; Future _avatarEncodeTail = Future.value(); @@ -44,14 +41,14 @@ Future cacheBuzzPushProfileEvents( if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { return; } - final verified = _boundedNewestEvents( + final verified = _newestVerifiedEvents( events, kind: 0, - maximum: _maximumPresentationProfiles, scope: (event) => event.pubkey.toLowerCase(), ).values.toList(); if (verified.isEmpty) return; - await _invokeBestEffort('cachePresentationProfiles', { + await _invokeBestEffort({ + 'section': 'profiles', 'communityId': communityID, 'events': [for (final event in verified) event.toJson()], }); @@ -68,14 +65,12 @@ Future cacheBuzzPushChannelEvents( communityID.isEmpty) { return; } - final batch = selectBoundedPushChannelEvents( - metadataEvents, - membershipEvents, - ); + final batch = selectPushChannelEvents(metadataEvents, membershipEvents); final verifiedMetadata = batch.metadata; final verifiedMembership = batch.membership; if (verifiedMetadata.isEmpty && verifiedMembership.isEmpty) return; - await _invokeBestEffort('cachePresentationChannels', { + await _invokeBestEffort({ + 'section': 'channels', 'communityId': communityID, 'metadataEvents': [for (final event in verifiedMetadata) event.toJson()], 'membershipEvents': [ @@ -84,25 +79,22 @@ Future cacheBuzzPushChannelEvents( }); } -/// Selects a bounded, paired set of verified channel metadata and membership events. +/// Selects the newest paired verified channel metadata and membership events. @visibleForTesting ({List metadata, List membership}) -selectBoundedPushChannelEvents( +selectPushChannelEvents( Iterable metadataEvents, - Iterable membershipEvents, { - @visibleForTesting int maximumChannels = _maximumPresentationChannels, -}) { - final verifiedMembershipByChannel = _boundedNewestEvents( + Iterable membershipEvents, +) { + final verifiedMembershipByChannel = _newestVerifiedEvents( membershipEvents, kind: 39002, - maximum: maximumChannels, scope: (event) => event.getTagValue('d'), ); final selectedChannelIDs = verifiedMembershipByChannel.keys.toSet(); - final verifiedMetadataByChannel = _boundedNewestEvents( + final verifiedMetadataByChannel = _newestVerifiedEvents( metadataEvents, kind: 39000, - maximum: maximumChannels, scope: (event) => event.getTagValue('d'), allowedScopes: selectedChannelIDs.isEmpty ? null : selectedChannelIDs, ); @@ -120,15 +112,13 @@ selectBoundedPushChannelEvents( return (metadata: verifiedMetadata, membership: verifiedMembership); } -Map _boundedNewestEvents( +Map _newestVerifiedEvents( Iterable events, { required int kind, - required int maximum, required String? Function(NostrEvent event) scope, Set? allowedScopes, }) { final selected = {}; - if (maximum <= 0) return selected; for (final event in events) { if (event.kind != kind || !isVerifiedPushPresentationEvent(event)) continue; final key = scope(event); @@ -139,18 +129,7 @@ Map _boundedNewestEvents( if (_isNewerEvent(event, existing)) selected[key] = event; continue; } - if (selected.length < maximum) { - selected[key] = event; - continue; - } - final oldest = selected.entries.reduce( - (left, right) => _isNewerEvent(left.value, right.value) ? right : left, - ); - if (_isNewerEvent(event, oldest.value)) { - selected - ..remove(oldest.key) - ..[key] = event; - } + selected[key] = event; } return selected; } @@ -183,7 +162,8 @@ Future cacheBuzzPushAvatarFromLoadedBytes( try { final png = await _boundedAvatarPNG(sourceBytes); if (png == null) return; - await _invokeBestEffort('cachePresentationAvatar', { + await _invokeBestEffort({ + 'section': 'avatar', 'communityId': communityID, 'sourceUrl': sourceURL, 'png': png, @@ -193,15 +173,15 @@ Future cacheBuzzPushAvatarFromLoadedBytes( } } -Future _invokeBestEffort( - String method, - Map arguments, -) async { +Future _invokeBestEffort(Map arguments) async { try { - await _pushPresentationChannel.invokeMethod(method, arguments); + await _pushPresentationChannel.invokeMethod( + 'syncPushSnapshot', + arguments, + ); pushPresentationCacheError.value = null; } on MissingPluginException { - // Push-free builds and non-Runner embeddings intentionally omit the bridge. + // Non-Runner embeddings do not provide the native snapshot bridge. } catch (error, stackTrace) { pushPresentationCacheError.value = error.toString(); debugPrint('Push presentation cache update failed: $error'); diff --git a/mobile/lib/shared/push/push_snapshot.dart b/mobile/lib/shared/push/push_snapshot.dart index 2760d26ae8..f269d05c0c 100644 --- a/mobile/lib/shared/push/push_snapshot.dart +++ b/mobile/lib/shared/push/push_snapshot.dart @@ -6,22 +6,33 @@ class BuzzPushCommunitySnapshot { final String name; final String relayUrl; final String? pubkey; - final BuzzPushLeaseSubscriptionState pushSubscriptionState; + final List subscriptions; - const BuzzPushCommunitySnapshot({ + BuzzPushCommunitySnapshot({ required this.id, required this.name, required this.relayUrl, this.pubkey, - required this.pushSubscriptionState, - }); + required Iterable subscriptions, + }) : subscriptions = List.unmodifiable(subscriptions); Map toJson() => { 'id': id, 'name': name, 'relayUrl': relayUrl, if (pubkey != null) 'pubkey': pubkey, - 'pushSubscriptionState': pushSubscriptionState.toJson(), + 'policies': [ + for (final subscription in subscriptions) + { + 'filter': subscription.filter.toJson(), + if (subscription.ignore.isNotEmpty) + 'ignore': [ + for (final filter in subscription.ignore) filter.toJson(), + ], + if (subscription.suppress != null) + 'suppress': subscription.suppress!.toJson(), + }, + ], }; factory BuzzPushCommunitySnapshot.fromJson(Map json) { @@ -30,9 +41,13 @@ class BuzzPushCommunitySnapshot { name: json['name'] as String, relayUrl: json['relayUrl'] as String, pubkey: json['pubkey'] as String?, - pushSubscriptionState: BuzzPushLeaseSubscriptionState.fromJson( - Map.from(json['pushSubscriptionState'] as Map), - ), + subscriptions: [ + for (final raw in json['policies'] as List) + BuzzPushSubscription.fromJson({ + ...Map.from(raw as Map), + 'class': 'default', + }), + ], ); } } diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart index acd2e96e0a..277031e84a 100644 --- a/mobile/lib/shared/push/push_subscription.dart +++ b/mobile/lib/shared/push/push_subscription.dart @@ -12,7 +12,7 @@ const buzzPushMaxSubscriptions = 16; const buzzPushMaxIgnoreFilters = 8; const buzzPushHellthreadParticipantLimit = 20; -const _supportedNotificationClasses = {'silent', 'default', 'time_sensitive'}; +const _supportedNotificationClasses = {'default'}; const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'}; final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$'); final _channelIdPattern = RegExp( @@ -185,24 +185,16 @@ class BuzzPushLeaseSubscriptionState { /// Monotonic generation of the relay-facing kind-30350 lease. final int? acceptedGeneration; - /// Generation sealed into the gateway's opaque relay delegation grant. - final int? acceptedGrantGeneration; - final String? acceptedInstallationId; - const BuzzPushLeaseSubscriptionState.desired({ this.desired = const [], this.accepted, this.acceptedGeneration, - this.acceptedGrantGeneration, - this.acceptedInstallationId, }) : authority = BuzzPushLeaseSubscriptionAuthority.desired; BuzzPushLeaseSubscriptionState.accepted({ required Iterable desired, required Iterable acceptedSubscriptions, required this.acceptedGeneration, - required this.acceptedGrantGeneration, - required this.acceptedInstallationId, }) : authority = BuzzPushLeaseSubscriptionAuthority.accepted, desired = List.unmodifiable(desired), accepted = List.unmodifiable(acceptedSubscriptions) { @@ -211,16 +203,6 @@ class BuzzPushLeaseSubscriptionState { 'Accepted push authority requires a positive lease generation.', ); } - if (acceptedGrantGeneration == null || acceptedGrantGeneration! <= 0) { - throw const FormatException( - 'Accepted push authority requires a positive gateway grant generation.', - ); - } - if (acceptedInstallationId == null || acceptedInstallationId!.isEmpty) { - throw const FormatException( - 'Accepted push authority requires an installation ID.', - ); - } } List get authoritative => switch (authority) { @@ -238,16 +220,12 @@ class BuzzPushLeaseSubscriptionState { desired: updated, accepted: accepted, acceptedGeneration: acceptedGeneration, - acceptedGrantGeneration: acceptedGrantGeneration, - acceptedInstallationId: acceptedInstallationId, ), BuzzPushLeaseSubscriptionAuthority.accepted => BuzzPushLeaseSubscriptionState.accepted( desired: updated, acceptedSubscriptions: accepted!, acceptedGeneration: acceptedGeneration, - acceptedGrantGeneration: acceptedGrantGeneration, - acceptedInstallationId: acceptedInstallationId, ), }; } @@ -255,14 +233,10 @@ class BuzzPushLeaseSubscriptionState { BuzzPushLeaseSubscriptionState withAccepted({ required Iterable subscriptions, required int generation, - required int grantGeneration, - required String installationId, }) => BuzzPushLeaseSubscriptionState.accepted( desired: desired, acceptedSubscriptions: subscriptions, acceptedGeneration: generation, - acceptedGrantGeneration: grantGeneration, - acceptedInstallationId: installationId, ); Map toJson() => { @@ -271,10 +245,6 @@ class BuzzPushLeaseSubscriptionState { if (accepted != null) 'accepted': [for (final subscription in accepted!) subscription.toJson()], if (acceptedGeneration != null) 'acceptedGeneration': acceptedGeneration, - if (acceptedGrantGeneration != null) - 'acceptedGrantGeneration': acceptedGrantGeneration, - if (acceptedInstallationId != null) - 'acceptedInstallationId': acceptedInstallationId, }; factory BuzzPushLeaseSubscriptionState.fromJson(Map json) { @@ -283,8 +253,6 @@ class BuzzPushLeaseSubscriptionState { 'desired', 'accepted', 'acceptedGeneration', - 'acceptedGrantGeneration', - 'acceptedInstallationId', }, 'push subscription state'); final authority = json['authority']; final desired = _subscriptionList( @@ -297,45 +265,22 @@ class BuzzPushLeaseSubscriptionState { ? null : _subscriptionList(acceptedRaw, 'accepted'); final acceptedGeneration = json['acceptedGeneration']; - // Pre-MVP snapshots coupled relay and gateway generations; interpreting - // that single value as both preserves them until the next publication. - final acceptedGrantGeneration = - json['acceptedGrantGeneration'] ?? acceptedGeneration; - final acceptedInstallationId = json['acceptedInstallationId']; if (acceptedGeneration != null && acceptedGeneration is! int) { throw const FormatException( 'Accepted push lease generation must be an integer.', ); } - if (acceptedGrantGeneration != null && acceptedGrantGeneration is! int) { - throw const FormatException( - 'Accepted gateway grant generation must be an integer.', - ); - } - if (acceptedInstallationId != null && acceptedInstallationId is! String) { - throw const FormatException( - 'Accepted push installation ID must be a string.', - ); - } return switch (authority) { 'desired' => BuzzPushLeaseSubscriptionState.desired( desired: desired, accepted: accepted, acceptedGeneration: acceptedGeneration as int?, - acceptedGrantGeneration: acceptedGrantGeneration as int?, - acceptedInstallationId: acceptedInstallationId as String?, ), - 'accepted' - when accepted != null && - acceptedGeneration is int && - acceptedGrantGeneration is int && - acceptedInstallationId is String => + 'accepted' when accepted != null && acceptedGeneration is int => BuzzPushLeaseSubscriptionState.accepted( desired: desired, acceptedSubscriptions: accepted, acceptedGeneration: acceptedGeneration, - acceptedGrantGeneration: acceptedGrantGeneration, - acceptedInstallationId: acceptedInstallationId, ), 'accepted' => throw const FormatException( 'Accepted push authority requires accepted subscriptions and generations.', diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index 8cf1d5091a..da1746df06 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -189,6 +189,7 @@ void main() { container: container, child: MaterialApp( home: DeepLinkDispatcher( + key: const ValueKey('before-community-switch'), destinationBuilder: (channel, link) => _CapturedDestination(channel: channel, link: link), child: const Scaffold(body: SizedBox()), @@ -199,6 +200,25 @@ void main() { await tester.pumpAndSettle(); expect(await storage.loadActiveId(), _notificationCommunity.id); + expect(container.read(pendingDeepLinkProvider), link); + + // Production remounts the community-scoped app subtree after a switch. + // The parked link is consumed by the replacement dispatcher. + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: DeepLinkDispatcher( + key: const ValueKey('after-community-switch'), + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final destination = tester.widget<_CapturedDestination>( find.byType(_CapturedDestination), ); diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart index e174941f74..7f7ea4b79e 100644 --- a/mobile/test/shared/push/dev_push_lease_test.dart +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -258,16 +258,6 @@ void main() { ); }); - test('descriptor rejects urgent kinds outside push kinds', () { - final information = _descriptorJson(relay.public); - (information['push'] as Map)['urgent_kinds'] = [7]; - - expect( - () => BuzzPushLeaseDescriptor.fromRelayInformation(information), - throwsA(isA()), - ); - }); - test('descriptor rejects unsupported h grammar', () { final information = _descriptorJson(relay.public); (information['push'] as Map)['h_grammar'] = 'opaque'; @@ -361,10 +351,9 @@ Map _descriptorJson(String relayPubkey) => { {'id': 'buzz-ios-dogfood', 'transport': 'apns'}, ], 'push_kinds': [9, 40002, 45001, 45003], - 'urgent_kinds': [], 'h_grammar': 'uuid-v4-lowercase', 'class_support': { - 'apns': ['silent', 'default', 'time_sensitive'], + 'apns': ['default'], }, 'limitation': { 'max_lease_ttl': 2592000, diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index c54a86cdd1..6c0a5a938d 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -100,7 +100,7 @@ void main() { if (call.method == 'endpointGrants') { return [_grantMap('new-grant')]; } - if (call.method == 'saveCommunitySnapshot') { + if (call.method == 'syncPushSnapshot') { snapshotArguments.add(call.arguments); return null; } @@ -142,20 +142,18 @@ void main() { 'endpointGrants', 'enrollPush', 'endpointGrants', - 'saveCommunitySnapshot', + 'syncPushSnapshot', ]); expect(snapshotArguments, [ { + 'section': 'communities', 'communities': [ { 'id': 'community-id', 'name': 'Community', 'relayUrl': 'wss://relay.example/', 'pubkey': 'd' * 64, - 'pushSubscriptionState': { - 'authority': 'desired', - 'desired': [], - }, + 'policies': [], }, ], 'signingKeys': {}, diff --git a/mobile/test/shared/push/push_presentation_cache_test.dart b/mobile/test/shared/push/push_presentation_cache_test.dart index 21ec3c8c6c..fff3ef7257 100644 --- a/mobile/test/shared/push/push_presentation_cache_test.dart +++ b/mobile/test/shared/push/push_presentation_cache_test.dart @@ -61,51 +61,47 @@ void main() { ); }); - test( - 'bounded channel selection keeps metadata paired with selected rosters', - () { - NostrEvent signedChannelEvent(int kind, String channelID, int createdAt) { - final signed = nostr.Event.from( - kind: kind, - content: '', - tags: [ - ['d', channelID], - if (kind == 39002) - [ - 'p', - '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', - ], - ], - secretKey: secretKey, - createdAt: createdAt, - ); - return NostrEvent.fromJson(signed.toMap()); - } - - final batch = selectBoundedPushChannelEvents( - [ - signedChannelEvent(39000, 'channel-0', 100), - signedChannelEvent(39000, 'channel-1', 300), - signedChannelEvent(39000, 'channel-2', 200), + test('channel selection keeps newest metadata paired with rosters', () { + NostrEvent signedChannelEvent(int kind, String channelID, int createdAt) { + final signed = nostr.Event.from( + kind: kind, + content: '', + tags: [ + ['d', channelID], + if (kind == 39002) + [ + 'p', + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + ], ], - [ - signedChannelEvent(39002, 'channel-0', 300), - signedChannelEvent(39002, 'channel-1', 200), - signedChannelEvent(39002, 'channel-2', 100), - ], - maximumChannels: 2, + secretKey: secretKey, + createdAt: createdAt, ); + return NostrEvent.fromJson(signed.toMap()); + } - expect(batch.metadata.map((event) => event.getTagValue('d')).toSet(), { - 'channel-0', - 'channel-1', - }); - expect(batch.membership.map((event) => event.getTagValue('d')).toSet(), { - 'channel-0', - 'channel-1', - }); - }, - ); + final batch = selectPushChannelEvents( + [ + signedChannelEvent(39000, 'channel-0', 100), + signedChannelEvent(39000, 'channel-1', 300), + signedChannelEvent(39000, 'channel-1', 200), + signedChannelEvent(39000, 'metadata-only', 400), + ], + [ + signedChannelEvent(39002, 'channel-0', 300), + signedChannelEvent(39002, 'channel-1', 200), + ], + ); + + expect(batch.metadata.map((event) => event.getTagValue('d')).toSet(), { + 'channel-0', + 'channel-1', + }); + expect(batch.membership.map((event) => event.getTagValue('d')).toSet(), { + 'channel-0', + 'channel-1', + }); + }); test('rejects changed content and malformed signatures', () { final signed = nostr.Event.from( diff --git a/mobile/test/shared/push/push_snapshot_test.dart b/mobile/test/shared/push/push_snapshot_test.dart index 730edf8e3f..27c2c5ad21 100644 --- a/mobile/test/shared/push/push_snapshot_test.dart +++ b/mobile/test/shared/push/push_snapshot_test.dart @@ -3,7 +3,7 @@ import 'package:buzz/shared/push/push_subscription.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('push community snapshot carries explicit subscription authority', () { + test('push community snapshot carries flattened resolution policies', () { final subscription = buildDesiredBuzzPushSubscriptions( myPubkey: 'a' * 64, ).single; @@ -12,17 +12,12 @@ void main() { name: 'Team', relayUrl: 'https://relay.example.com', pubkey: 'a' * 64, - pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( - desired: [subscription], - ), + subscriptions: [subscription], ); final decoded = BuzzPushCommunitySnapshot.fromJson(snapshot.toJson()); expect(decoded.toJson(), snapshot.toJson()); - expect( - decoded.pushSubscriptionState.authority, - BuzzPushLeaseSubscriptionAuthority.desired, - ); + expect(decoded.subscriptions, hasLength(1)); }); } diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart index 9c666a9e06..359a3496df 100644 --- a/mobile/test/shared/push/push_subscription_test.dart +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -43,27 +43,15 @@ void main() { ); }); - test('tracks relay lease and gateway grant generations independently', () { + test('persists only the relay-accepted lease generation', () { final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; - final state = - BuzzPushLeaseSubscriptionState.desired( - desired: [subscription], - ).withAccepted( - subscriptions: [subscription], - generation: 9, - grantGeneration: 3, - installationId: 'c' * 32, - ); + final state = BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 9); final decoded = BuzzPushLeaseSubscriptionState.fromJson(state.toJson()); expect(decoded.acceptedGeneration, 9); - expect(decoded.acceptedGrantGeneration, 3); - - final migrated = BuzzPushLeaseSubscriptionState.fromJson({ - ...state.toJson()..remove('acceptedGrantGeneration'), - }); - expect(migrated.acceptedGeneration, 9); - expect(migrated.acceptedGrantGeneration, 9); + expect(decoded.toJson(), state.toJson()); }); test('builds aligned self and unmuted channel subscriptions', () { diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index faef913bb9..9dca8c82c3 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -107,9 +107,6 @@ run_unit_tests() { run_test_step "buzz-push-gateway tests" \ cargo test -p buzz-push-gateway -- --nocapture - run_test_step "buzz-push-gateway dev App Attest bypass tests" \ - cargo test -p buzz-push-gateway --features dev-app-attest-bypass -- --nocapture - # Kubernetes backend provider: pure decision layers driven by a fake # substrate, no cluster. Mirrors the nextest path in `just test-unit` — # the two lists must stay in step or the fallback silently covers less. diff --git a/scripts/test-ios-pbxproj-semantics.py b/scripts/test-ios-pbxproj-semantics.py deleted file mode 100755 index e2b2015904..0000000000 --- a/scripts/test-ios-pbxproj-semantics.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python3 -"""Validate the semantic iOS target/configuration table via macOS plutil.""" - -import json -import subprocess -import sys -from pathlib import Path - -PBXPROJ = Path("mobile/ios/Runner.xcodeproj/project.pbxproj") -# This asserts plutil-resolved pbxproj declarations, not object-key uniqueness or -# the final xcconfig-expanded bundle identity. -EXPECTED_ROWS = """\ -NotificationService Debug Flutter/Debug.xcconfig $(BUZZ_DEVELOPMENT_TEAM) NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService -NotificationService Profile Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService -NotificationService Release Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService -Runner Debug Flutter/Debug.xcconfig $(BUZZ_DEVELOPMENT_TEAM) Runner/Runner.entitlements $(BUNDLE_IDENTIFIER) -Runner Profile Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) Runner/Runner.entitlements $(BUNDLE_IDENTIFIER) -Runner Release Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) Runner/Runner.entitlements $(BUNDLE_IDENTIFIER) -RunnerTests Debug Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests -RunnerTests Profile Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests -RunnerTests Release Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests -""".splitlines() - - -def parse_project(path: Path) -> dict: - result = subprocess.run( - ["plutil", "-convert", "json", "-o", "-", str(path)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - detail = result.stderr.strip() or "no error detail" - raise RuntimeError(f"plutil failed to parse {path}: {detail}") - if not result.stdout: - raise RuntimeError(f"plutil returned no JSON for {path}") - return json.loads(result.stdout) - - -def semantic_rows(project: dict) -> list[str]: - objects = project["objects"] - root = objects[project["rootObject"]] - rows = [] - for target_id in root["targets"]: - target = objects[target_id] - if target.get("isa") != "PBXNativeTarget": - continue - configuration_list = objects[target["buildConfigurationList"]] - for configuration_id in configuration_list["buildConfigurations"]: - configuration = objects[configuration_id] - settings = configuration.get("buildSettings", {}) - base_reference = configuration.get("baseConfigurationReference") - base_path = objects.get(base_reference, {}).get("path", "-") - rows.append( - " ".join( - [ - target.get("name", "-"), - configuration.get("name", "-"), - base_path, - settings.get("DEVELOPMENT_TEAM", "-"), - settings.get("CODE_SIGN_ENTITLEMENTS", "-"), - settings.get("PRODUCT_BUNDLE_IDENTIFIER", "-"), - ] - ) - ) - return sorted(rows) - - -def main() -> int: - path = Path(sys.argv[1]) if len(sys.argv) > 1 else PBXPROJ - try: - actual = semantic_rows(parse_project(path)) - except (KeyError, TypeError, json.JSONDecodeError, RuntimeError) as error: - print(f"FAIL: {error}", file=sys.stderr) - return 1 - - if actual != EXPECTED_ROWS: - print("FAIL: unexpected iOS target configuration semantics", file=sys.stderr) - print("expected:", *EXPECTED_ROWS, sep="\n", file=sys.stderr) - print("actual:", *actual, sep="\n", file=sys.stderr) - return 1 - - print("iOS target configuration semantics match") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/test-mobile-worktree-overrides.sh b/scripts/test-mobile-worktree-overrides.sh index 279d31174e..70637f5225 100755 --- a/scripts/test-mobile-worktree-overrides.sh +++ b/scripts/test-mobile-worktree-overrides.sh @@ -152,8 +152,6 @@ fi # ── Tracked build files: overrides are debug-only, release stays production ── debug_xcconfig="$repo_root/mobile/ios/Flutter/Debug.xcconfig" release_xcconfig="$repo_root/mobile/ios/Flutter/Release.xcconfig" -pbxproj="$repo_root/mobile/ios/Runner.xcodeproj/project.pbxproj" -runner_entitlements="$repo_root/mobile/ios/Runner/Runner.entitlements" gradle="$repo_root/mobile/android/app/build.gradle.kts" manifest="$repo_root/mobile/android/app/src/main/AndroidManifest.xml" plist="$repo_root/mobile/ios/Runner/Info.plist" @@ -185,348 +183,6 @@ grep -q '^APP_DISPLAY_NAME = Buzz$' "$release_xcconfig" \ && pass "Release.xcconfig keeps the production display name" \ || fail "Release.xcconfig must keep APP_DISPLAY_NAME = Buzz" -# These checks assert declarations in the two tracked xcconfigs only. They do -# not prove resolved build settings. The later gitignored includes -# (WorktreeOverrides.xcconfig and AppOverrides.xcconfig) can override these -# declarations and are explicitly outside this tracked-source assertion. The -# value check and declaration census are complementary: xcconfig is last-wins, -# while the census deliberately flags even a harmless duplicate declaration so -# a human reviews the changed declaration surface. -assert_xcconfig_value() { - # $1: file, $2: anchored value regex, $3: pass/failure description - if grep -qE "$2" "$1"; then - pass "$3" - else - fail "$3" - fi -} - -assert_xcconfig_declaration_count() { - # $1: file, $2: key, $3: expected count, $4: configuration label - local file="$1" key="$2" expected="$3" label="$4" count - count=$(grep -cE "^[[:space:]]*$key([[:space:]]*\[[^]]*\])*[[:space:]]*=" "$file" || true) - if [[ "$count" -eq "$expected" ]]; then - if [[ "$expected" -eq 0 ]]; then - pass "$label $key has no tracked declaration sites" - elif [[ "$expected" -eq 1 ]]; then - pass "$label $key has one tracked declaration site" - else - pass "$label $key has $count tracked declaration sites" - fi - elif [[ "$expected" -eq 0 ]]; then - fail "$label $key has $count tracked declaration sites; expected zero" - elif [[ "$expected" -eq 1 ]]; then - fail "$label $key has $count tracked declaration sites; expected exactly one" - else - fail "$label $key has $count tracked declaration sites; expected $expected" - fi -} - -assert_single_xcconfig_declaration() { - # $1: file, $2: key, $3: configuration label - assert_xcconfig_declaration_count "$1" "$2" 1 "$3" -} - -for config in "$debug_xcconfig" "$release_xcconfig"; do - assert_xcconfig_value "$config" \ - '^BUZZ_APP_GROUP_IDENTIFIER = group\.\$\(BUNDLE_IDENTIFIER\)$' \ - "$(basename "$config") derives the push App Group from the bundle" - assert_xcconfig_value "$config" \ - '^BUZZ_KEYCHAIN_ACCESS_GROUP = \$\(BUNDLE_IDENTIFIER\)$' \ - "$(basename "$config") derives the push Keychain group from the bundle" -done -assert_xcconfig_value "$debug_xcconfig" \ - '^BUZZ_IOS_PUSH_ENVIRONMENT = development$' \ - "Debug uses sandbox APNs" -assert_xcconfig_value "$debug_xcconfig" \ - '^BUZZ_APP_ATTEST_ENVIRONMENT = development$' \ - "Debug uses development App Attest" -assert_xcconfig_value "$release_xcconfig" \ - '^BUZZ_IOS_PUSH_ENVIRONMENT = production$' \ - "Release uses production APNs" -assert_xcconfig_value "$release_xcconfig" \ - '^BUZZ_APP_ATTEST_ENVIRONMENT = production$' \ - "Release uses production App Attest" -assert_xcconfig_value "$release_xcconfig" \ - '^CODE_SIGN_STYLE = Automatic$' \ - "Release code signing style is declared as automatic" -assert_xcconfig_value "$release_xcconfig" \ - '^CODE_SIGN_IDENTITY = iPhone Developer$' \ - "Release code signing identity is declared as iPhone Developer" - -for key in BUNDLE_IDENTIFIER; do - assert_single_xcconfig_declaration "$debug_xcconfig" "$key" "Debug" - assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release" -done - -for key in BUZZ_KEYCHAIN_ACCESS_GROUP BUZZ_IOS_PUSH_ENVIRONMENT BUZZ_APP_ATTEST_ENVIRONMENT BUZZ_APP_GROUP_IDENTIFIER; do - assert_single_xcconfig_declaration "$debug_xcconfig" "$key" "Debug" - assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release" -done - -for key in BUZZ_PUSH_ENABLED BUZZ_CODE_SIGN_ENTITLEMENTS EXCLUDED_SOURCE_FILE_NAMES DART_DEFINES; do - assert_xcconfig_declaration_count "$debug_xcconfig" "$key" 0 "Debug" - assert_xcconfig_declaration_count "$release_xcconfig" "$key" 0 "Release" -done - -for key in CODE_SIGN_STYLE CODE_SIGN_IDENTITY; do - assert_xcconfig_declaration_count "$debug_xcconfig" "$key" 0 "Debug" - assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release" -done - -assert_xcconfig_declaration_count \ - "$debug_xcconfig" PROVISIONING_PROFILE_SPECIFIER 0 "Debug" -assert_xcconfig_declaration_count \ - "$release_xcconfig" PROVISIONING_PROFILE_SPECIFIER 0 "Release" -assert_single_xcconfig_declaration "$debug_xcconfig" APP_DISPLAY_NAME "Debug" -assert_single_xcconfig_declaration "$release_xcconfig" APP_DISPLAY_NAME "Release" - -# SWIFT_ACTIVE_COMPILATION_CONDITIONS is checked separately from the closed -# identity census above. This is a tracked-source assertion only; resolved Xcode -# build settings are intentionally outside this Linux-compatible test. -assert_xcconfig_value "$debug_xcconfig" \ - '^SWIFT_ACTIVE_COMPILATION_CONDITIONS = \$\(inherited\) DEBUG$' \ - "Debug Swift compilation conditions inherit DEBUG" - -debug_swift_condition_count=$(grep -cE \ - '^[[:space:]]*SWIFT_ACTIVE_COMPILATION_CONDITIONS([[:space:]]*\[[^]]*\])*[[:space:]]*=' \ - "$debug_xcconfig" || true) -if [[ "$debug_swift_condition_count" -eq 1 ]]; then - pass "Debug SWIFT_ACTIVE_COMPILATION_CONDITIONS has one tracked declaration site" -else - fail "Debug SWIFT_ACTIVE_COMPILATION_CONDITIONS has $debug_swift_condition_count tracked declaration sites; expected exactly one" -fi - -release_swift_condition_count=$(grep -cE \ - '^[[:space:]]*SWIFT_ACTIVE_COMPILATION_CONDITIONS([[:space:]]*\[[^]]*\])*[[:space:]]*=' \ - "$release_xcconfig" || true) -if [[ "$release_swift_condition_count" -eq 0 ]]; then - pass "Release SWIFT_ACTIVE_COMPILATION_CONDITIONS has no tracked declaration sites" -else - fail "Release SWIFT_ACTIVE_COMPILATION_CONDITIONS has $release_swift_condition_count tracked declaration sites; expected zero" -fi - -grep -q 'aps-environment' "$runner_entitlements" \ - && pass "Runner entitlements always include APNs support" \ - || fail "Runner entitlements must include aps-environment" - -# Split the retired identifiers so the regression test does not match itself. -retired_bundle_id='com.buzz.buzz'"Mobile" -if git -C "$repo_root" grep -q -F "$retired_bundle_id"; then - fail "tracked files must not retain the retired iOS bundle identifier" -else - pass "tracked files do not retain the retired iOS bundle identifier" -fi -grep -q 'com.apple.developer.devicecheck.appattest-environment' "$runner_entitlements" \ - && pass "Runner uses the App Attest entitlement key accepted by Apple" \ - || fail "Runner must use com.apple.developer.devicecheck.appattest-environment" -retired_entitlement_key='com.apple.developer.app-attest.'"environment" -if grep -q "$retired_entitlement_key" "$runner_entitlements"; then - fail "Runner must not retain the invalid App Attest entitlement key" -else - pass "Runner omits the invalid App Attest entitlement key" -fi - -duplicate_pbx_object_ids=$(awk ' - # This bounded source-level smoke check recognizes the current two-tab - # object-key spellings. It is not a general OpenStep uniqueness check: - # measured exclusions include a comment before the key, a presentation - # comment spanning lines, and one-tab indentation (jb_b1/jb_b2/jb_b6). - # The macOS semantic check below owns their resolved build consequences. - function decomment(s, head, tailpart) { - while (match(s, /\/\*/)) { - head = substr(s, 1, RSTART - 1) - tailpart = substr(s, RSTART + 2) - if (!match(tailpart, /\*\//)) return head " " - s = head " " substr(tailpart, RSTART + RLENGTH) - } - return s - } - - /^\t\t/ { - line = decomment($0) - if (match(line, /^\t\t"?[[:alnum:]]+"?[[:space:]]*=/)) { - object_id = substr(line, RSTART, RLENGTH) - sub(/^\t\t"?/, "", object_id) - sub(/"?[[:space:]]*=$/, "", object_id) - if (++object_id_count[object_id] == 2) print object_id - } - } -' "$pbxproj" | sort) -if [[ -n "$duplicate_pbx_object_ids" ]]; then - fail "recognized iOS project object identifiers repeat: $(printf '%s\n' "$duplicate_pbx_object_ids" | paste -sd ' ' -)" -else - pass "recognized iOS project object identifiers do not repeat" -fi - -signing_map=$(awk ' - # PBX comments are separators, not text: strip them before parsing any - # object so a comment cannot hide a duplicate key from the ambiguity count. - function decomment(s, head, tailpart) { - while (match(s, /\/\*/)) { - head = substr(s, 1, RSTART - 1) - tailpart = substr(s, RSTART + 2) - if (!match(tailpart, /\*\//)) { return head " " } - s = head " " substr(tailpart, RSTART + RLENGTH) - } - return s - } - - FNR == 1 { pass++ } - - # Pass 1 indexes xcconfig paths and follows each PBXNativeTarget to its - # actual configuration-list object. Target names come from object fields, - # not presentation comments. - pass == 1 { - if (/isa[[:space:]]*=[[:space:]]*PBXFileReference/ && /\.xcconfig/) { - declaration = decomment($0) - if (match(declaration, /=[[:space:]]*\{[[:space:]]*isa[[:space:]]*=[[:space:]]*PBXFileReference[[:space:]]*;/)) { - declaration = substr(declaration, RSTART + RLENGTH) - } else { - declaration = "" - } - sub(/\}.*/, "", declaration) - xcconfig_path = "MISSING_PATH" - rest = declaration - path_matches = 0 - while (match(rest, /(^|;)[[:space:]]*path[[:space:]]*=[[:space:]]*[^;]+/)) { - candidate = substr(rest, RSTART, RLENGTH) - rest = substr(rest, RSTART + RLENGTH) - sub(/^;?[[:space:]]*path[[:space:]]*=[[:space:]]*/, "", candidate) - gsub(/"/, "", candidate) - sub(/[[:space:]]+$/, "", candidate) - path_matches++ - xcconfig_path = candidate - } - # More than one `path =` in one object means a decoy (a quoted value or - # an embedded comment) is shadowing the real key. Never guess which one - # the build uses: fail the row loudly instead. - if (path_matches > 1) xcconfig_path = "AMBIGUOUS_PATH" - xcconfig_paths[$1] = xcconfig_path - } - - if (/\/\* Begin PBXNativeTarget section \*\//) { - in_native_targets = 1 - next - } - if (/\/\* End PBXNativeTarget section \*\//) { - in_native_targets = 0 - next - } - if (!in_native_targets) next - - if (/^\t\t[^[:space:]]+ .* = \{$/) { - native_target_id = $1 - native_target_name = "" - native_target_list = "" - next - } - if (native_target_id != "" && /^\t\t\tname = /) { - native_target_name = $0 - sub(/^.*= */, "", native_target_name) - sub(/;.*/, "", native_target_name) - gsub(/"/, "", native_target_name) - next - } - if (native_target_id != "" && /^\t\t\tbuildConfigurationList = /) { - native_target_list = $3 - next - } - if (native_target_id != "" && /^\t\t\};/) { - if (native_target_list != "") { - if (native_target_name == "") native_target_name = "UNNAMED:" native_target_id - if (native_target_list in list_owners) { - list_owners[native_target_list] = "DUPLICATE:" list_owners[native_target_list] "+" native_target_name - } else { - list_owners[native_target_list] = native_target_name - } - } - native_target_id = "" - } - next - } - - # Pass 2 maps build-configuration object IDs through only those lists that - # real native targets own. PBXProject and other unowned lists are ignored. - pass == 2 { - if (/\/\* Begin XCConfigurationList section \*\//) { - in_configuration_lists = 1 - next - } - if (/\/\* End XCConfigurationList section \*\//) { - in_configuration_lists = 0 - next - } - if (!in_configuration_lists) next - - if (/^\t\t[^[:space:]]+ .* = \{$/) { - configuration_list_id = $1 - configuration_list_owner = configuration_list_id in list_owners ? list_owners[configuration_list_id] : "" - next - } - if (/buildConfigurations = \(/) { - in_list_configurations = 1 - next - } - if (in_list_configurations && /\);/) { - in_list_configurations = 0 - next - } - if (in_list_configurations && $1 ~ /^[[:alnum:]]+$/ && configuration_list_owner != "") { - if ($1 in targets) targets[$1] = "DUPLICATE:" targets[$1] "+" configuration_list_owner - else targets[$1] = configuration_list_owner - } - next - } - - # Pass 3 emits one row for each team-bearing build configuration. - !in_build_configuration && /\/\* (Debug|Release|Profile) \*\/ = \{/ { - in_build_configuration = 1 - configuration_id = $1 - configuration = $3 - base_configuration = "NONE" - team = "" - entitlements = "NONE" - depth = 0 - } - - in_build_configuration { - if (/baseConfigurationReference =/) { - base_configuration = $3 in xcconfig_paths ? xcconfig_paths[$3] : "UNRESOLVED:" $3 - } - if (/DEVELOPMENT_TEAM =/) { - team = $0 - sub(/^.*= */, "", team) - sub(/;.*/, "", team) - } - if (/CODE_SIGN_ENTITLEMENTS =/) { - entitlements = $0 - sub(/^.*= */, "", entitlements) - sub(/;.*/, "", entitlements) - } - - depth += gsub(/\{/, "{") - gsub(/\}/, "}") - if (depth == 0) { - if (team != "") { - target_name = configuration_id in targets ? targets[configuration_id] : "UNMAPPED:" configuration_id - print target_name, configuration, base_configuration, team, entitlements - } - in_build_configuration = 0 - } - } -' "$pbxproj" "$pbxproj" "$pbxproj" | sort) -expected_signing_map=$(printf '%s\n' \ - 'NotificationService Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ - 'NotificationService Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ - 'NotificationService Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ - 'Runner Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" Runner/Runner.entitlements' \ - 'Runner Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" Runner/Runner.entitlements' \ - 'Runner Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" Runner/Runner.entitlements') -if [[ "$signing_map" == "$expected_signing_map" ]]; then - pass "Runner and NotificationService signing settings match each build configuration" -else - fail "unexpected iOS signing map: $signing_map" -fi grep -q '$(APP_DISPLAY_NAME)' "$plist" \ && pass "Info.plist display name resolves from build settings" \ || fail "Info.plist CFBundleDisplayName must be \$(APP_DISPLAY_NAME)"