diff --git a/VISION_MOBILE.md b/VISION_MOBILE.md index ac18821f6e..c397d05535 100644 --- a/VISION_MOBILE.md +++ b/VISION_MOBILE.md @@ -38,7 +38,7 @@ The following are preferred architectural approaches in service of the experienc - We want to minimize the average time elapsed between (a) merging a change and (b) the change appearing in Comp Portal and the app stores. - We should always have a release `Waiting for Review` / `In Review` in the iOS App Store. Immediately a release is approved, we submit a new one. -- We should build and upload release candidates frequently. Every RC should go to Comp Portal and TestFlight. Building and distributing an RC from a commit on main should require only a single click/command. When the time arrives to submit the next release to public app stores, there should already be a suitable RC uploaded and dogfooded. +- We should build and upload release candidates frequently. Every RC should go to Comp Portal and TestFlight. Building and distributing an RC from a commit on main should require only a single click/command. When the time arrives to submit the next release to public app stores, there should already be a suitable RC uploaded and tested. ## Critical functionality diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index a7376a1e6a..b6ead54e06 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -705,9 +705,14 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 59); + assert_eq!(migrations.len(), 60); assert_eq!(migrations[58].version, 59); assert!(migrations[58].sql.as_str().contains("buzz.push_enabled")); + assert_eq!(migrations[59].version, 60); + assert!(migrations[59] + .sql + .as_str() + .contains("ALTER TABLE push_leases DROP COLUMN app_profile")); assert_eq!(migrations[55].version, 56); assert!(migrations[55] .sql @@ -1329,7 +1334,7 @@ mod postgres_tests { assert!(dogfood_profile .contains("DROP CONSTRAINT push_gateway_installations_app_profile_check")); assert!(dogfood_profile.contains("CHECK (app_profile = 'buzz-ios-dogfood')")); - assert!(desired_schema.contains("CHECK (app_profile = 'buzz-ios-dogfood')")); + assert!(!desired_schema.contains("app_profile")); // Drop the Phase-A NIP-FI relay-side authority ledger (0041 + 0042). // OSS Buzz is stateless for identity (spec v2, PR #7214); the durable @@ -3258,4 +3263,53 @@ mod postgres_tests { ); } } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn migration_0060_refuses_active_legacy_leases_without_mutation() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + run_migrations_through(&pool, 59) + .await + .expect("legacy schema"); + let community = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities(id,host) VALUES($1,'push-cutover.example')") + .bind(community) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO push_leases(community_id,author,installation_id,source_event_id,source_created_at,generation,active,app_profile,endpoint_hash,endpoint_grant,max_class,subscriptions,expires_at) VALUES($1,$2,'legacy',$3,1,1,true,'buzz-ios-dogfood',$4,'opaque','default','[]',2000000000)") + .bind(community).bind(vec![1_u8;32]).bind(vec![2_u8;32]).bind(vec![3_u8;32]) + .execute(&pool).await.unwrap(); + let error = run_migrations(&pool) + .await + .expect_err("active legacy lease blocks cutover"); + assert!( + error.to_string().contains("no active legacy push leases"), + "{error}" + ); + let preserved: (bool, String) = + sqlx::query_as("SELECT active,app_profile FROM push_leases WHERE community_id=$1") + .bind(community) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(preserved, (true, "buzz-ios-dogfood".to_owned())); + sqlx::query("UPDATE push_leases SET active=false,app_profile=NULL,endpoint_hash=NULL,endpoint_grant=NULL,max_class=NULL,subscriptions=NULL WHERE community_id=$1") + .bind(community).execute(&pool).await.unwrap(); + run_migrations(&pool) + .await + .expect("inactive legacy lease permits cutover"); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_leases WHERE community_id=$1 AND NOT active", + ) + .bind(community) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); + assert!(sqlx::query("SELECT app_profile FROM push_leases") + .execute(&pool) + .await + .is_err()); + } } diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index 3998e10b3b..2e0e260bd2 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -819,9 +819,9 @@ async fn push_gateway_profile_migration_converges_brownfield_authority() { .await .expect("insert delegation for legacy installation"); - migration::run_migrations(&pool) + migration::run_migrations_through(&pool, 43) .await - .expect("apply dogfood-only migration"); + .expect("apply historical profile migration"); let legacy_installations: i64 = sqlx::query_scalar("SELECT count(*) FROM push_gateway_installations") diff --git a/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs b/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs index 5a61e288d3..b32b8bfbd4 100644 --- a/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs +++ b/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs @@ -202,7 +202,6 @@ async fn activate_lease( expires_at, }, push::ActiveLease { - app_profile: "ios-production", endpoint_hash: &endpoint_hash, endpoint_grant: "test-grant", max_class: "default", @@ -1015,10 +1014,10 @@ async fn assert_async_isolation(db: &Db, community: CommunityId, keys: &Keys) { .unwrap(); sqlx::query( "INSERT INTO push_leases (community_id, author, installation_id, source_event_id, \ - source_created_at, generation, active, endpoint_enabled, app_profile, endpoint_hash, \ + source_created_at, generation, active, endpoint_enabled, endpoint_hash, \ endpoint_grant, max_class, subscriptions, expires_at, updated_at) \ SELECT $1, author, installation_id, source_event_id, source_created_at, generation, \ - active, endpoint_enabled, app_profile, endpoint_hash, endpoint_grant, max_class, \ + active, endpoint_enabled, endpoint_hash, endpoint_grant, max_class, \ subscriptions, expires_at, clock_timestamp() FROM push_leases WHERE community_id=$2", ) .bind(later.as_uuid()) diff --git a/crates/buzz-db/src/store/push.rs b/crates/buzz-db/src/store/push.rs index 011a9434ed..1a95aecf70 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -72,8 +72,6 @@ pub struct LeaseVersion<'a> { /// Effective fields for an active APNs lease. #[derive(Debug, Clone, Copy)] pub struct ActiveLease<'a> { - /// Application profile selected from the executor descriptor. - pub app_profile: &'a str, /// SHA-256 of the platform endpoint. pub endpoint_hash: &'a [u8], /// Opaque endpoint grant issued by the stateless gateway. @@ -300,7 +298,7 @@ pub async fn accept_lease_event( // uniqueness forever. The author lock makes this cleanup atomic with the // subsequent author-wide checks and replacement. sqlx::query( - "UPDATE push_leases SET active=false, endpoint_enabled=false, app_profile=NULL, \ + "UPDATE push_leases SET active=false, endpoint_enabled=false, \ endpoint_hash=NULL, endpoint_grant=NULL, max_class=NULL, subscriptions=NULL, updated_at=now() \ WHERE community_id=$1 AND author=$2 AND active \ AND expires_at <= EXTRACT(EPOCH FROM now())::bigint", @@ -323,12 +321,11 @@ pub async fn accept_lease_event( return Ok(AcceptLeaseOutcome::LeaseQuotaExceeded); } let duplicate: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM push_leases WHERE community_id=$1 AND author=$2 AND installation_id<>$3 AND active AND app_profile=$4 AND endpoint_hash=$5)", + "SELECT EXISTS(SELECT 1 FROM push_leases WHERE community_id=$1 AND author=$2 AND installation_id<>$3 AND active AND endpoint_hash=$4)", ) .bind(community.as_uuid()) .bind(author) .bind(installation_id) - .bind(active.app_profile) .bind(active.endpoint_hash) .fetch_one(tx.conn()) .await?; @@ -375,11 +372,10 @@ pub async fn accept_lease_event( ) .await?; - let (is_active, app_profile, endpoint_hash, endpoint_grant, max_class, subscriptions) = active - .map_or((false, None, None, None, None, None), |active| { + let (is_active, endpoint_hash, endpoint_grant, max_class, subscriptions) = active + .map_or((false, None, None, None, None), |active| { ( true, - Some(active.app_profile), Some(active.endpoint_hash), Some(active.endpoint_grant), Some(active.max_class), @@ -388,18 +384,18 @@ pub async fn accept_lease_event( }); if let Err(error) = sqlx::query( r#"INSERT INTO push_leases (community_id,author,installation_id,source_event_id, - source_created_at,generation,active,app_profile,endpoint_hash,endpoint_grant,max_class, - subscriptions,expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + source_created_at,generation,active,endpoint_hash,endpoint_grant,max_class, + subscriptions,expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (community_id,author,installation_id) DO UPDATE SET source_event_id=EXCLUDED.source_event_id, source_created_at=EXCLUDED.source_created_at, generation=EXCLUDED.generation, active=EXCLUDED.active, endpoint_enabled=true, - app_profile=EXCLUDED.app_profile, endpoint_hash=EXCLUDED.endpoint_hash, + endpoint_hash=EXCLUDED.endpoint_hash, endpoint_grant=EXCLUDED.endpoint_grant, max_class=EXCLUDED.max_class, subscriptions=EXCLUDED.subscriptions, expires_at=EXCLUDED.expires_at, updated_at=now()"#, ) .bind(community.as_uuid()).bind(author).bind(installation_id) .bind(version.source_event_id).bind(version.source_created_at).bind(version.generation) - .bind(is_active).bind(app_profile).bind(endpoint_hash).bind(endpoint_grant) + .bind(is_active).bind(endpoint_hash).bind(endpoint_grant) .bind(max_class).bind(subscriptions).bind(version.expires_at) .execute(tx.conn()).await { @@ -474,18 +470,16 @@ async fn replace_lease( version: LeaseVersion<'_>, active: Option>, ) -> Result { - let (is_active, app_profile, endpoint_hash, endpoint_grant, max_class, subscriptions) = - match active { - Some(active) => ( - true, - Some(active.app_profile), - Some(active.endpoint_hash), - Some(active.endpoint_grant), - Some(active.max_class), - Some(active.subscriptions), - ), - None => (false, None, None, None, None, None), - }; + let (is_active, endpoint_hash, endpoint_grant, max_class, subscriptions) = match active { + Some(active) => ( + true, + Some(active.endpoint_hash), + Some(active.endpoint_grant), + Some(active.max_class), + Some(active.subscriptions), + ), + None => (false, None, None, None, None), + }; // T1b: an activating replacement can flip the community from "no eligible // lease" to "eligible"; serialize it against the event producer's shared @@ -505,16 +499,15 @@ async fn replace_lease( r#" INSERT INTO push_leases ( community_id, author, installation_id, source_event_id, - source_created_at, generation, active, app_profile, endpoint_hash, + source_created_at, generation, active, endpoint_hash, endpoint_grant, max_class, subscriptions, expires_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (community_id, author, installation_id) DO UPDATE SET source_event_id = EXCLUDED.source_event_id, source_created_at = EXCLUDED.source_created_at, generation = EXCLUDED.generation, active = EXCLUDED.active, endpoint_enabled = true, - app_profile = EXCLUDED.app_profile, endpoint_hash = EXCLUDED.endpoint_hash, endpoint_grant = EXCLUDED.endpoint_grant, max_class = EXCLUDED.max_class, @@ -539,7 +532,6 @@ async fn replace_lease( .bind(version.source_created_at) .bind(version.generation) .bind(is_active) - .bind(app_profile) .bind(endpoint_hash) .bind(endpoint_grant) .bind(max_class) @@ -1574,7 +1566,6 @@ mod postgres_tests { installation, version(generation as u8, generation * 10, generation), ActiveLease { - app_profile: "ios-production", endpoint_hash: endpoint, endpoint_grant: "opaque-grant", max_class: "default", @@ -1609,7 +1600,6 @@ mod postgres_tests { expires_at: 200, }, Some(ActiveLease { - app_profile: "ios-production", endpoint_hash: &endpoint, endpoint_grant: "opaque-grant", max_class: "not-a-class", @@ -1663,7 +1653,6 @@ mod postgres_tests { expires_at: 1, }, Some(ActiveLease { - app_profile: "ios-production", endpoint_hash: &endpoint, endpoint_grant: "opaque-grant", max_class: "default", @@ -1689,7 +1678,6 @@ mod postgres_tests { expires_at: i64::MAX / 2, }, Some(ActiveLease { - app_profile: "ios-production", endpoint_hash: &[43; 32], endpoint_grant: "replacement-grant", max_class: "default", @@ -1738,7 +1726,6 @@ mod postgres_tests { expires_at: 200, }, ActiveLease { - app_profile: "ios-production", endpoint_hash: &endpoint, endpoint_grant: "opaque-grant", max_class: "default", @@ -1800,7 +1787,6 @@ mod postgres_tests { "install", version(3, 15, 99), ActiveLease { - app_profile: "ios-production", endpoint_hash: &endpoint, endpoint_grant: "grant", max_class: "default", @@ -2789,7 +2775,6 @@ mod postgres_tests { "install", version(1, 10, 1), ActiveLease { - app_profile: "ios-production", endpoint_hash: &[92; 32], endpoint_grant: "opaque-grant", max_class: "default", diff --git a/crates/buzz-push-gateway/migrations/0006_single_application.sql b/crates/buzz-push-gateway/migrations/0006_single_application.sql new file mode 100644 index 0000000000..b05c01ca4b --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0006_single_application.sql @@ -0,0 +1,12 @@ +-- The new capability starts with fresh installation authority. Do not silently +-- retain fingerprints made with the removed application-profile namespace. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM push_gateway_installations) THEN + RAISE EXCEPTION 'buzz-push-v1 requires an empty gateway authority store. Stop old gateway replicas and provision a fresh dedicated database before upgrading.'; + END IF; +END +$$; +ALTER TABLE push_gateway_installations DROP COLUMN app_profile; +CREATE UNIQUE INDEX push_gateway_installations_active_token + ON push_gateway_installations (token_fingerprint) WHERE revoked_at IS NULL; diff --git a/crates/buzz-push-gateway/src/apns.rs b/crates/buzz-push-gateway/src/apns.rs index f8f19486c1..8fe125b576 100644 --- a/crates/buzz-push-gateway/src/apns.rs +++ b/crates/buzz-push-gateway/src/apns.rs @@ -287,12 +287,12 @@ mod tests { } #[tokio::test] - #[ignore = "requires the exported dogfood Apple Push Services PEM"] + #[ignore = "requires the exported configured Apple Push Services PEM"] async fn live_sandbox_probe_reports_literal_status_and_body() { let cert_path = std::env::var("BUZZ_PUSH_LIVE_APNS_CERT_PATH") - .expect("set BUZZ_PUSH_LIVE_APNS_CERT_PATH to the dogfood identity PEM"); + .expect("set BUZZ_PUSH_LIVE_APNS_CERT_PATH to the configured identity PEM"); let topic = std::env::var("BUZZ_PUSH_LIVE_APNS_TOPIC") - .expect("set BUZZ_PUSH_LIVE_APNS_TOPIC to the dogfood bundle id"); + .expect("set BUZZ_PUSH_LIVE_APNS_TOPIC to the configured bundle id"); let identity = std::fs::read(cert_path).unwrap(); let transport = ApnsTransport::certificate(&identity, topic, ApnsEnvironment::Sandbox).unwrap(); diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 9f71f6ee69..898ea67b01 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -4,7 +4,6 @@ //! does not prove an Apple-issued binding between that key and the APNs token; //! accepting the directly submitted token is the protocol's explicit bootstrap //! assumption. -use crate::model::AppProfile; use async_trait::async_trait; use std::collections::HashMap; use std::sync::Mutex; @@ -31,7 +30,6 @@ pub struct NewInstallation { pub app_attest_key_id: Vec, pub app_attest_public_key: Vec, pub assertion_counter: u32, - pub profile: AppProfile, pub token_ciphertext: Vec, pub token_fingerprint: [u8; 32], pub endpoint_epoch: i64, @@ -44,7 +42,6 @@ pub struct Installation { pub app_attest_key_id: Vec, pub app_attest_public_key: Vec, pub assertion_counter: u32, - pub profile: AppProfile, pub token_ciphertext: Vec, pub token_fingerprint: [u8; 32], pub endpoint_epoch: i64, @@ -69,7 +66,6 @@ pub struct DeliveryAuthority { pub delegation_id: Uuid, pub installation_id: Uuid, pub relay_pubkey: String, - pub profile: AppProfile, pub token_ciphertext: Vec, pub endpoint_epoch: i64, pub generation: i64, @@ -135,7 +131,6 @@ pub trait AuthorityStore: Send + Sync { async fn matching_installation( &self, app_attest_key_id: &[u8], - profile: AppProfile, token_fingerprint: [u8; 32], endpoint_epoch: i64, expires_at: i64, @@ -211,7 +206,7 @@ pub trait AuthorityStore: Send + Sync { struct MemoryState { challenges: HashMap, installations: HashMap, - token_owners: HashMap<(AppProfile, [u8; 32]), Uuid>, + token_owners: HashMap<[u8; 32], Uuid>, delegations: HashMap<(Uuid, String), Delegation>, delegation_ids: HashMap, delivery_auth_replays: HashMap<(String, String), i64>, @@ -273,7 +268,7 @@ impl AuthorityStore for MemoryAuthorityStore { now: i64, ) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; - let token_key = (n.profile, n.token_fingerprint); + let token_key = n.token_fingerprint; if s.installations.contains_key(&n.id) { return Err(AuthorityError::Rejected); } @@ -282,8 +277,7 @@ impl AuthorityStore for MemoryAuthorityStore { .values() .filter(|installation| { installation.app_attest_key_id == n.app_attest_key_id - || (installation.profile == n.profile - && installation.token_fingerprint == n.token_fingerprint) + || installation.token_fingerprint == n.token_fingerprint }) .map(|installation| installation.id) .collect::>(); @@ -305,7 +299,7 @@ impl AuthorityStore for MemoryAuthorityStore { .collect::>(); for id in replaced { if let Some(old) = s.installations.remove(&id) { - s.token_owners.remove(&(old.profile, old.token_fingerprint)); + s.token_owners.remove(&old.token_fingerprint); } s.delegations .retain(|(installation_id, _), _| *installation_id != id); @@ -320,7 +314,7 @@ impl AuthorityStore for MemoryAuthorityStore { app_attest_key_id: n.app_attest_key_id, app_attest_public_key: n.app_attest_public_key, assertion_counter: n.assertion_counter, - profile: n.profile, + token_ciphertext: n.token_ciphertext, token_fingerprint: n.token_fingerprint, endpoint_epoch: n.endpoint_epoch, @@ -358,7 +352,6 @@ impl AuthorityStore for MemoryAuthorityStore { async fn matching_installation( &self, key_id: &[u8], - profile: AppProfile, fingerprint: [u8; 32], epoch: i64, expires_at: i64, @@ -371,7 +364,6 @@ impl AuthorityStore for MemoryAuthorityStore { !installation.revoked && installation.expires_at >= now && installation.app_attest_key_id == key_id - && installation.profile == profile && installation.token_fingerprint == fingerprint && installation.endpoint_epoch == epoch && installation.expires_at == expires_at @@ -443,21 +435,21 @@ impl AuthorityStore for MemoryAuthorityStore { return Err(AuthorityError::Rejected); } let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; - let (profile, old_fingerprint) = { + let old_fingerprint = { let i = s.installations.get(&id).ok_or(AuthorityError::Rejected)?; if i.revoked || i.endpoint_epoch != expected { return Err(AuthorityError::Rejected); } - (i.profile, i.token_fingerprint) + i.token_fingerprint }; - let token_key = (profile, fingerprint); + let token_key = fingerprint; if s.token_owners .get(&token_key) .is_some_and(|owner| *owner != id) { return Err(AuthorityError::Rejected); } - s.token_owners.remove(&(profile, old_fingerprint)); + s.token_owners.remove(&old_fingerprint); s.token_owners.insert(token_key, id); let i = s .installations @@ -559,7 +551,7 @@ impl AuthorityStore for MemoryAuthorityStore { delegation_id, installation_id: i.id, relay_pubkey: relay.to_owned(), - profile: i.profile, + token_ciphertext: i.token_ciphertext.clone(), endpoint_epoch: epoch, generation, @@ -650,7 +642,7 @@ mod tests { app_attest_key_id: vec![1], app_attest_public_key: vec![2; 33], assertion_counter: 0, - profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![3], token_fingerprint: [4; 32], endpoint_epoch: 1, @@ -681,14 +673,14 @@ mod tests { let store = store().await; let recovered = store - .matching_installation(&[1], AppProfile::BuzzIosDogfood, [4; 32], 1, 2_000, 1_001) + .matching_installation(&[1], [4; 32], 1, 2_000, 1_001) .await .unwrap() .expect("exact replay finds the committed installation"); assert_eq!(recovered.id, Uuid::from_u128(1)); assert!(store - .matching_installation(&[1], AppProfile::BuzzIosDogfood, [5; 32], 1, 2_000, 1_001,) + .matching_installation(&[1], [5; 32], 1, 2_000, 1_001,) .await .unwrap() .is_none()); @@ -785,7 +777,7 @@ mod tests { app_attest_key_id: vec![1], app_attest_public_key: vec![5; 33], assertion_counter: 0, - profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![6], token_fingerprint: [4; 32], endpoint_epoch: 1, @@ -951,7 +943,7 @@ mod tests { app_attest_key_id: vec![1], app_attest_public_key: vec![5; 33], assertion_counter: 0, - profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![6], token_fingerprint: [4; 32], endpoint_epoch: 1, diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index 0f3014affc..2074f48d41 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -8,8 +8,9 @@ pub enum ApnsEnvironment { Sandbox, } +/// The single server-owned Apple application and APNs connection identity. #[derive(Debug, Clone)] -pub struct AppProfileConfig { +pub struct ApplicationConfig { pub app_attest_app_id: String, /// Exact Apple attestation environment accepted for enrollment. pub app_attest_environment: AppAttestEnvironment, @@ -42,8 +43,8 @@ pub struct Config { pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, pub endpoint_quota_max_deliveries: i64, - /// Server-owned dogfood application identity and APNs transport. - pub profile: AppProfileConfig, + /// Server-owned application identity and APNs transport. + pub application: ApplicationConfig, pub database_url: String, pub app_attest_root_cert_path: PathBuf, /// Ordered current key first, followed by decrypt-only predecessors. @@ -106,11 +107,11 @@ fn parse_keyring( Ok(keys) } -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"; +fn parse_application(e: &HashMap) -> Result { + let app_id_key = "BUZZ_PUSH_APP_ATTEST_APP_ID"; + let cert_key = "BUZZ_PUSH_APNS_CERT_PATH"; + let topic_key = "BUZZ_PUSH_APNS_TOPIC"; + let environment_key = "BUZZ_PUSH_APNS_ENVIRONMENT"; let required = |key: &'static str| { e.get(key) .map(String::as_str) @@ -134,7 +135,7 @@ fn parse_profile(e: &HashMap) -> Result ApnsEnvironment::Sandbox, Some(_) => return Err(ConfigError::Invalid(environment_key)), }; - Ok(AppProfileConfig { + Ok(ApplicationConfig { app_attest_app_id, app_attest_environment, apns_cert_path, @@ -195,7 +196,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 profile = parse_profile(e)?; + let application = parse_application(e)?; let bind_addr = e .get("BUZZ_PUSH_BIND_ADDR") .map(String::as_str) @@ -215,7 +216,7 @@ impl Config { max_installation_lifetime_seconds, endpoint_quota_window_seconds, endpoint_quota_max_deliveries, - profile, + application, database_url: req(e, "DATABASE_URL")?.to_owned(), app_attest_root_cert_path: req(e, "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH")?.into(), grant_keys, @@ -233,7 +234,7 @@ mod tests { assert_eq!( Config::from_map(&env) .unwrap() - .profile + .application .app_attest_environment, AppAttestEnvironment::Production ); @@ -248,7 +249,7 @@ mod tests { assert_eq!( Config::from_map(&env) .unwrap() - .profile + .application .app_attest_environment, AppAttestEnvironment::Production ); @@ -260,7 +261,7 @@ mod tests { assert_eq!( Config::from_map(&env) .unwrap() - .profile + .application .app_attest_environment, AppAttestEnvironment::Development ); @@ -295,43 +296,37 @@ mod tests { "postgres://buzz:test@localhost/buzz".into(), // sadscan:disable np.postgres.1 ), ( - "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID".into(), - "TEAM.xyz.block.buzz.dogfood.mobile".into(), + "BUZZ_PUSH_APP_ATTEST_APP_ID".into(), + "TEAM.com.example.buzz".into(), ), ( "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH".into(), "/apple-root.pem".into(), ), ( - "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH".into(), - "/dogfood-identity.pem".into(), - ), - ( - "BUZZ_PUSH_DOGFOOD_APNS_TOPIC".into(), - "xyz.block.buzz.dogfood.mobile".into(), - ), - ( - "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), - "production".into(), + "BUZZ_PUSH_APNS_CERT_PATH".into(), + "/apns-identity.pem".into(), ), + ("BUZZ_PUSH_APNS_TOPIC".into(), "com.example.buzz".into()), + ("BUZZ_PUSH_APNS_ENVIRONMENT".into(), "production".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 dogfood_profile_requires_server_owned_identity_and_certificate() { + fn application_requires_server_owned_identity_and_certificate() { let config = Config::from_map(&base()).unwrap(); assert_eq!( - config.profile.apns_cert_path, - PathBuf::from("/dogfood-identity.pem") + config.application.apns_cert_path, + PathBuf::from("/apns-identity.pem") ); - assert_eq!(config.profile.apns_topic, "xyz.block.buzz.dogfood.mobile"); + assert_eq!(config.application.apns_topic, "com.example.buzz"); for variable in [ - "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", - "BUZZ_PUSH_DOGFOOD_APNS_TOPIC", - "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", + "BUZZ_PUSH_APNS_CERT_PATH", + "BUZZ_PUSH_APNS_TOPIC", + "BUZZ_PUSH_APP_ATTEST_APP_ID", ] { let mut env = base(); env.remove(variable); @@ -374,8 +369,8 @@ mod tests { #[test] fn malformed_security_configuration_fails_startup() { for (key, value) in [ - ("BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", ""), - ("BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", "staging"), + ("BUZZ_PUSH_APP_ATTEST_APP_ID", ""), + ("BUZZ_PUSH_APNS_ENVIRONMENT", "staging"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "31536001"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "2.592e+06"), diff --git a/crates/buzz-push-gateway/src/grant.rs b/crates/buzz-push-gateway/src/grant.rs index 8eda1d7ce8..2b41feea50 100644 --- a/crates/buzz-push-gateway/src/grant.rs +++ b/crates/buzz-push-gateway/src/grant.rs @@ -159,7 +159,7 @@ mod tests { v: 1, delegation_id: uuid::Uuid::nil(), relay_pubkey: "11".repeat(32), - app_profile: AppProfile::BuzzIosDogfood, + endpoint_epoch: 1, generation: 2, expires_at: 99, diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 1becbddedb..ca1e18936c 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -36,8 +36,9 @@ use std::{ use tower::limit::ConcurrencyLimitLayer; use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer}; +/// Verification and delivery services for this gateway's configured application. #[derive(Clone)] -pub struct ProfileRuntime { +pub struct ApplicationRuntime { pub app_attest: Arc, pub transport: Arc, } @@ -47,9 +48,9 @@ pub struct AppState { pub grant_keyring: Arc, pub authority: Arc, pub token_keyring: 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, + /// Server-owned application identity and APNs transport. + /// App Attest verifies the configured app ID. + pub application: Arc, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, @@ -138,12 +139,10 @@ fn endpoint_bytes(endpoint: &str) -> Option> { .then(|| hex::decode(endpoint).ok()) .flatten() } -fn endpoint_fingerprint(profile: AppProfile, token: &[u8]) -> [u8; 32] { +fn endpoint_fingerprint(token: &[u8]) -> [u8; 32] { use sha2::{Digest, Sha256}; let mut h = Sha256::new(); - h.update(b"buzz-apns-endpoint-v1\0"); - h.update(profile.as_str().as_bytes()); - h.update([0]); + h.update(b"buzz-apns-endpoint-v2\0"); h.update(token); h.finalize().into() } @@ -194,7 +193,7 @@ struct EnrollTranscript<'a> { challenge_id: uuid::Uuid, challenge: &'a str, key_id: &'a str, - app_profile: AppProfile, + endpoint: &'a str, endpoint_epoch: i64, expires_at: i64, @@ -209,9 +208,6 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Some(v) => v, None => 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 @@ -229,7 +225,7 @@ async fn enroll(State(s): State, body: Bytes) -> Response { challenge_id: r.challenge_id, challenge: &r.challenge, key_id: &r.key_id, - app_profile: r.app_profile, + endpoint: &r.endpoint, endpoint_epoch: r.endpoint_epoch, expires_at: r.expires_at, @@ -238,21 +234,19 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Some(v) => v, None => return error(StatusCode::BAD_REQUEST, "invalid_request"), }; - let verified = - match s - .profile - .app_attest - .verify_attestation(&r.attestation, &r.key_id, signed.as_bytes()) - { - Ok(value) => value, - Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), - }; - let fingerprint = endpoint_fingerprint(r.app_profile, &token); + let verified = match s.application.app_attest.verify_attestation( + &r.attestation, + &r.key_id, + signed.as_bytes(), + ) { + Ok(value) => value, + Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), + }; + let fingerprint = endpoint_fingerprint(&token); match s .authority .matching_installation( &verified.key_id, - r.app_profile, fingerprint, r.endpoint_epoch, r.expires_at, @@ -292,7 +286,7 @@ async fn enroll(State(s): State, body: Bytes) -> Response { app_attest_key_id: verified.key_id, app_attest_public_key: verified.public_key, assertion_counter: 0, - profile: r.app_profile, + token_ciphertext: ciphertext, token_fingerprint: fingerprint, endpoint_epoch: 1, @@ -337,13 +331,10 @@ async fn verify_installation_assertion( s.authority.installation(installation_id, now).await } .map_err(authority_error)?; - 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 = s - .profile + .application .app_attest .verify_assertion( assertion, @@ -442,10 +433,6 @@ async fn delegate(State(s): State, body: Bytes) -> Response { v: WIRE_VERSION, delegation_id: d.id, relay_pubkey: d.relay_pubkey, - app_profile: match s.authority.installation(d.installation_id, now).await { - Ok(i) => i.profile, - Err(e) => return authority_error(e), - }, endpoint_epoch: d.endpoint_epoch, generation: d.generation, expires_at: d.expires_at, @@ -486,14 +473,6 @@ async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { { return error(StatusCode::BAD_REQUEST, "invalid_request"); } - let installation = match s - .authority - .installation(r.installation_handle, (s.now)()) - .await - { - Ok(i) => i, - Err(e) => return authority_error(e), - }; let t = RotateTranscript { v: r.v, audience: ROTATE_ENDPOINT_AUDIENCE, @@ -531,7 +510,7 @@ async fn rotate_endpoint(State(s): State, body: Bytes) -> Response { r.endpoint_epoch, r.new_endpoint_epoch, ciphertext, - endpoint_fingerprint(installation.profile, &token), + endpoint_fingerprint(&token), ) .await { @@ -735,23 +714,7 @@ async fn deliver( return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"); } }; - if permit.authority.profile != grant.app_profile { - crate::metrics::record_delivery_error("profile_mismatch"); - let _ = s - .authority - .finish_delivery(permit, DeliveryDisposition::Terminal) - .await; - return error(StatusCode::NOT_FOUND, "invalid_grant"); - } - 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 transport = Arc::clone(&s.application.transport); let endpoint = match s.token_keyring.open(&permit.authority.token_ciphertext) { Ok(token) => hex::encode(token), Err(_) => { @@ -921,7 +884,7 @@ mod request_limit_tests { fn state() -> AppState { let app_attest = AppAttestVerifier::new( - "TEAMID.xyz.block.buzz.dogfood.mobile".to_owned(), + "TEAMID.com.example.buzz".to_owned(), include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(), ) .expect("pinned Apple root fixture"); @@ -933,7 +896,7 @@ mod request_limit_tests { token_keyring: Arc::new( TokenKeyring::new(vec![TokenKey::new("test", &[2; 32]).unwrap()]).unwrap(), ), - profile: Arc::new(ProfileRuntime { + application: Arc::new(ApplicationRuntime { app_attest: Arc::new(app_attest), transport: Arc::new(NeverTransport), }), @@ -953,7 +916,7 @@ mod request_limit_tests { challenge: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0; 32]), key_id: STANDARD.encode([0; 32]), attestation: STANDARD.encode(vec![0; MAX_APP_ATTESTATION_BYTES]), - app_profile: AppProfile::BuzzIosDogfood, + endpoint: "ab".repeat(MAX_ENDPOINT_HEX_BYTES), endpoint_epoch: 1, expires_at: fixed_now() + 60, @@ -1085,7 +1048,7 @@ mod transcript_vector_tests { challenge_id: CHALLENGE_ID, challenge: CHALLENGE, key_id: KEY_ID, - app_profile: AppProfile::BuzzIosDogfood, + endpoint: ENDPOINT, endpoint_epoch: 1, expires_at: 1_752_624_000, diff --git a/crates/buzz-push-gateway/src/http_delivery_tests.rs b/crates/buzz-push-gateway/src/http_delivery_tests.rs index e5dd48f816..d72ef0d6d4 100644 --- a/crates/buzz-push-gateway/src/http_delivery_tests.rs +++ b/crates/buzz-push-gateway/src/http_delivery_tests.rs @@ -10,8 +10,8 @@ use crate::{ app_attest::AppAttestVerifier, authority::{AuthorityStore, Delegation, MemoryAuthorityStore, NewInstallation}, grant::{GrantKey, GrantKeyring}, - http::ProfileRuntime, - model::{AppProfile, EndpointGrant}, + http::ApplicationRuntime, + model::EndpointGrant, router, token::{TokenKey, TokenKeyring}, AppState, @@ -58,7 +58,7 @@ async fn fixture() -> (axum::Router, Keys, Vec, Arc) { app_attest_key_id: vec![3; 32], app_attest_public_key: vec![4; 65], assertion_counter: 0, - profile: AppProfile::BuzzIosDogfood, + token_ciphertext: token_keyring.seal(&[1, 2, 3, 4]).unwrap(), token_fingerprint: [5; 32], endpoint_epoch: 1, @@ -87,7 +87,7 @@ async fn fixture() -> (axum::Router, Keys, Vec, Arc) { v: 1, delegation_id, relay_pubkey: keys.public_key().to_hex(), - app_profile: AppProfile::BuzzIosDogfood, + endpoint_epoch: 1, generation: 1, expires_at: now() + 600, @@ -98,10 +98,10 @@ async fn fixture() -> (axum::Router, Keys, Vec, Arc) { grant_keyring, authority, token_keyring, - profile: Arc::new(ProfileRuntime { + application: Arc::new(ApplicationRuntime { app_attest: Arc::new( AppAttestVerifier::new( - "TEAMID.xyz.block.buzz.dogfood.mobile".into(), + "TEAMID.com.example.buzz".into(), include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(), ) .unwrap(), diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index cf21ea20d3..2a18d815a5 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -36,8 +36,8 @@ 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 configured = &c.profile; - let profile = { + let configured = &c.application; + let application = { let apple = AppAttestVerifier::with_environment( configured.app_attest_app_id.clone(), app_attest_root, @@ -49,7 +49,7 @@ async fn main() -> Result<(), Box> { configured.apns_topic.clone(), configured.apns_environment, )?); - buzz_push_gateway::http::ProfileRuntime { + buzz_push_gateway::http::ApplicationRuntime { app_attest: Arc::new(apple), transport, } @@ -96,7 +96,7 @@ async fn main() -> Result<(), Box> { grant_keyring: Arc::new(grant_keyring), authority, token_keyring: Arc::new(token_keyring), - profile: Arc::new(profile), + application: Arc::new(application), max_grant_lifetime_seconds: c.max_grant_lifetime_seconds, max_installation_lifetime_seconds: c.max_installation_lifetime_seconds, endpoint_quota_window_seconds: c.endpoint_quota_window_seconds, diff --git a/crates/buzz-push-gateway/src/model.rs b/crates/buzz-push-gateway/src/model.rs index 390f665d8a..441966bd20 100644 --- a/crates/buzz-push-gateway/src/model.rs +++ b/crates/buzz-push-gateway/src/model.rs @@ -16,19 +16,6 @@ pub const APNS_RECONNECT_PAYLOAD: &[u8] = br#"{"aps":{"alert":{"body":"Reconnect to your relay now"},"mutable-content":1}}"#; pub const WIRE_VERSION: u8 = 1; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum AppProfile { - BuzzIosDogfood, -} -impl AppProfile { - pub const fn as_str(self) -> &'static str { - match self { - Self::BuzzIosDogfood => "buzz-ios-dogfood", - } - } -} - /// Relay request. It deliberately has no application-payload field: /// the gateway emits one compiled-in APNs reconnect payload for every delivery. /// `endpoint_grant` is opaque authenticated ciphertext minted by the gateway @@ -51,7 +38,6 @@ pub struct EndpointGrant { pub v: u8, pub delegation_id: uuid::Uuid, pub relay_pubkey: String, - pub app_profile: AppProfile, pub endpoint_epoch: i64, pub generation: i64, pub expires_at: i64, @@ -81,7 +67,6 @@ pub struct InstallationEnrollRequest { pub challenge: String, pub key_id: String, pub attestation: String, - pub app_profile: AppProfile, pub endpoint: String, pub endpoint_epoch: i64, pub expires_at: i64, diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 2459cefe11..74a288827b 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -1,7 +1,6 @@ //! PostgreSQL authority store. Mutations use row locks and compare-and-swap //! predicates so counters, epochs, and generation tombstones only move forward. use crate::authority::*; -use crate::model::AppProfile; use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::{AssertSqlSafe, PgPool, Row}; @@ -13,6 +12,14 @@ mod bootstrap_postgres_tests; static GATEWAY_MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); +#[cfg(test)] +async fn migrate_gateway_through( + pool: &PgPool, + version: i64, +) -> Result<(), sqlx::migrate::MigrateError> { + GATEWAY_MIGRATOR.run_to(version, pool).await +} + #[derive(Clone)] pub struct PostgresAuthorityStore { pool: PgPool, @@ -70,12 +77,6 @@ fn at(ts: i64) -> Result, AuthorityError> { fn ts(v: DateTime) -> i64 { v.timestamp() } -fn profile(v: &str) -> Result { - match v { - "buzz-ios-dogfood" => Ok(AppProfile::BuzzIosDogfood), - _ => Err(AuthorityError::Unavailable), - } -} fn db(_: sqlx::Error) -> AuthorityError { AuthorityError::Unavailable } @@ -179,10 +180,9 @@ impl AuthorityStore for PostgresAuthorityStore { let mut tx = self.pool.begin().await.map_err(db)?; let now_at = at(now)?; let existing = sqlx::query( - "SELECT id,expires_at,revoked_at FROM push_gateway_installations WHERE app_attest_key_id=$1 OR (app_profile=$2 AND token_fingerprint=$3) FOR UPDATE", + "SELECT id,expires_at,revoked_at FROM push_gateway_installations WHERE app_attest_key_id=$1 OR token_fingerprint=$2 FOR UPDATE", ) .bind(&n.app_attest_key_id) - .bind(n.profile.as_str()) .bind(n.token_fingerprint.to_vec()) .fetch_all(&mut *tx) .await @@ -223,8 +223,8 @@ impl AuthorityStore for PostgresAuthorityStore { .await .map_err(db)?; } - let result = sqlx::query("INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING") - .bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.profile.as_str()).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&mut *tx).await.map_err(db)?; + let result = sqlx::query("INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING") + .bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&mut *tx).await.map_err(db)?; if result.rows_affected() != 1 { return Err(AuthorityError::Conflict); } @@ -240,7 +240,7 @@ impl AuthorityStore for PostgresAuthorityStore { app_attest_public_key: r.try_get("app_attest_public_key").map_err(db)?, assertion_counter: u32::try_from(r.try_get::("assertion_counter").map_err(db)?) .map_err(|_| AuthorityError::Unavailable)?, - profile: profile(r.try_get("app_profile").map_err(db)?)?, + token_ciphertext: r.try_get("token_ciphertext").map_err(db)?, token_fingerprint: bytes32(r.try_get("token_fingerprint").map_err(db)?)?, endpoint_epoch: r.try_get("endpoint_epoch").map_err(db)?, @@ -268,7 +268,7 @@ impl AuthorityStore for PostgresAuthorityStore { app_attest_public_key: r.try_get("app_attest_public_key").map_err(db)?, assertion_counter: u32::try_from(r.try_get::("assertion_counter").map_err(db)?) .map_err(|_| AuthorityError::Unavailable)?, - profile: profile(r.try_get("app_profile").map_err(db)?)?, + token_ciphertext: r.try_get("token_ciphertext").map_err(db)?, token_fingerprint: bytes32(r.try_get("token_fingerprint").map_err(db)?)?, endpoint_epoch: r.try_get("endpoint_epoch").map_err(db)?, @@ -282,15 +282,14 @@ impl AuthorityStore for PostgresAuthorityStore { async fn matching_installation( &self, key_id: &[u8], - app_profile: AppProfile, + token_fingerprint: [u8; 32], endpoint_epoch: i64, expires_at: i64, now: i64, ) -> Result, AuthorityError> { - let r = sqlx::query("SELECT * FROM push_gateway_installations WHERE app_attest_key_id=$1 AND app_profile=$2 AND token_fingerprint=$3 AND endpoint_epoch=$4 AND expires_at=$5 AND revoked_at IS NULL AND expires_at >= $6") + let r = sqlx::query("SELECT * FROM push_gateway_installations WHERE app_attest_key_id=$1 AND token_fingerprint=$2 AND endpoint_epoch=$3 AND expires_at=$4 AND revoked_at IS NULL AND expires_at >= $5") .bind(key_id) - .bind(app_profile.as_str()) .bind(token_fingerprint.to_vec()) .bind(endpoint_epoch) .bind(at(expires_at)?) @@ -308,7 +307,7 @@ impl AuthorityStore for PostgresAuthorityStore { r.try_get::("assertion_counter").map_err(db)?, ) .map_err(|_| AuthorityError::Unavailable)?, - profile: profile(r.try_get("app_profile").map_err(db)?)?, + token_ciphertext: r.try_get("token_ciphertext").map_err(db)?, token_fingerprint: bytes32(r.try_get("token_fingerprint").map_err(db)?)?, endpoint_epoch: r.try_get("endpoint_epoch").map_err(db)?, @@ -432,7 +431,7 @@ impl AuthorityStore for PostgresAuthorityStore { // Every authority mutation locks installation before delegation. Keep // this order here to avoid delivery-vs-refresh deadlocks. let i = sqlx::query( - "SELECT app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at,revoked_at + "SELECT token_ciphertext,token_fingerprint,endpoint_epoch,expires_at,revoked_at FROM push_gateway_installations WHERE id=(SELECT installation_id FROM push_gateway_delegations WHERE id=$1) FOR UPDATE", @@ -470,7 +469,7 @@ impl AuthorityStore for PostgresAuthorityStore { delegation_id: did, installation_id, relay_pubkey: relay.to_owned(), - profile: profile(i.try_get("app_profile").map_err(db)?)?, + token_ciphertext: i.try_get("token_ciphertext").map_err(db)?, endpoint_epoch: epoch, generation, @@ -647,7 +646,7 @@ mod postgres_tests { AND tablename='push_gateway_installations' AND indexname IN ( 'push_gateway_installations_active_app_attest_key', - 'push_gateway_installations_active_profile_token') + 'push_gateway_installations_active_token') AND indexdef LIKE '%WHERE (revoked_at IS NULL)%'", ) .fetch_one(&migration_pool) @@ -909,8 +908,8 @@ mod postgres_tests { async fn install_authority(pool: &PgPool) { let now = Utc::now(); sqlx::query( - "INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) - VALUES ($1,$2,$3,0,'buzz-ios-dogfood',$4,$5,1,$6)", + "INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) + VALUES ($1,$2,$3,0,$4,$5,1,$6)", ) .bind(Uuid::from_u128(1)) .bind(vec![1u8]) @@ -946,7 +945,7 @@ mod postgres_tests { app_attest_key_id: vec![1], app_attest_public_key: vec![2; 33], assertion_counter: 0, - profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![3], token_fingerprint: [4; 32], endpoint_epoch: 1, @@ -1012,7 +1011,7 @@ mod postgres_tests { app_attest_key_id: vec![1], app_attest_public_key: vec![2; 33], assertion_counter: 0, - profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![3], token_fingerprint: [4; 32], endpoint_epoch: 1, diff --git a/crates/buzz-push-gateway/src/postgres/bootstrap_tests.rs b/crates/buzz-push-gateway/src/postgres/bootstrap_tests.rs index 117cc798c2..4e45757b12 100644 --- a/crates/buzz-push-gateway/src/postgres/bootstrap_tests.rs +++ b/crates/buzz-push-gateway/src/postgres/bootstrap_tests.rs @@ -76,3 +76,64 @@ async fn bootstrap_refuses_legacy_data_before_migrations_and_allows_initialized_ .expect("remove isolated test fixture"); pool.close().await; } + +#[tokio::test] +#[ignore = "requires PostgreSQL"] +async fn single_application_cutover_refuses_populated_v5_authority() { + let url = std::env::var("BUZZ_TEST_DATABASE_URL").expect("test database URL"); + let pool = PgPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .unwrap(); + let schema = format!("cutover_{}", Uuid::new_v4().simple()); + sqlx::raw_sql(AssertSqlSafe(format!( + "CREATE SCHEMA {schema}; SET search_path TO {schema}" + ))) + .execute(&pool) + .await + .unwrap(); + migrate_gateway_through(&pool, 5).await.unwrap(); + sqlx::query("INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) VALUES($1,$2,$3,0,'buzz-ios-dogfood',$4,$5,1,now()+interval '1 day')") + .bind(Uuid::new_v4()).bind(vec![1_u8]).bind(vec![2_u8;33]).bind(vec![3_u8]).bind(vec![4_u8;32]) + .execute(&pool).await.unwrap(); + let error = PostgresAuthorityStore::apply_migrations_and_grants(&pool, "") + .await + .unwrap_err(); + assert!( + error.to_string().contains("empty gateway authority store"), + "{error}" + ); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_gateway_installations WHERE app_profile='buzz-ios-dogfood'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); + // Only the fixture deletes authority. Production refuses without mutation. + sqlx::query("DELETE FROM push_gateway_installations") + .execute(&pool) + .await + .unwrap(); + let error = PostgresAuthorityStore::apply_migrations_and_grants(&pool, "") + .await + .unwrap_err(); + assert!( + error.to_string().contains("runtime database role"), + "{error}" + ); + assert!( + sqlx::query("SELECT app_profile FROM push_gateway_installations") + .execute(&pool) + .await + .is_err() + ); + sqlx::raw_sql(AssertSqlSafe(format!( + "SET search_path TO public; DROP SCHEMA {schema} CASCADE" + ))) + .execute(&pool) + .await + .unwrap(); + pool.close().await; +} diff --git a/crates/buzz-push-gateway/tests/startup_app_attest.rs b/crates/buzz-push-gateway/tests/startup_app_attest.rs index e1e1ee3dcd..d2ff51a092 100644 --- a/crates/buzz-push-gateway/tests/startup_app_attest.rs +++ b/crates/buzz-push-gateway/tests/startup_app_attest.rs @@ -20,10 +20,7 @@ fn parsed_configuration_reaches_executable_verifier() { ), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "2592000".into()), ("DATABASE_URL", "unused".into()), - ( - "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", - "TEAM.test.app".into(), - ), + ("BUZZ_PUSH_APP_ATTEST_APP_ID", "TEAM.test.app".into()), ( "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH", format!( @@ -32,15 +29,12 @@ fn parsed_configuration_reaches_executable_verifier() { ), ), // Startup stops immediately after configuring the real verifier. - ("BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", "".into()), - ("BUZZ_PUSH_DOGFOOD_APNS_TOPIC", "test.app".into()), + ("BUZZ_PUSH_APNS_CERT_PATH", "".into()), + ("BUZZ_PUSH_APNS_TOPIC", "test.app".into()), ("RUST_LOG", "info".into()), ]); // A directory cannot be read as an APNs certificate, on every platform. - command.env( - "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", - env!("CARGO_MANIFEST_DIR"), - ); + command.env("BUZZ_PUSH_APNS_CERT_PATH", env!("CARGO_MANIFEST_DIR")); if let Some(setting) = setting { command.env("BUZZ_PUSH_APP_ATTEST_ENVIRONMENT", setting); } diff --git a/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json b/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json index 27b84035d6..e53a8e7741 100644 --- a/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json +++ b/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json @@ -1,5 +1,5 @@ { - "description": "Known-answer vectors for the exact App Attest transcript bytes defined by NIP-PL ('Exact App Attest transcript construction'). Generated by the gateway's own transcript encoder (crates/buzz-push-gateway/src/http.rs transcript()). Client canonical encoders (Swift NIP-PL iOS client) MUST reproduce `transcript` byte-for-byte; `sha256` is the hex digest of those UTF-8 bytes (the App Attest clientDataHash input for assertion routes, and the exact clientData for enrollment).", + "description": "Known-answer vectors for Buzz push App Attest transcripts. Swift and Rust must reproduce the exact bytes and SHA-256 digest.", "inputs": { "challenge_id": "11111111-1111-4111-8111-111111111111", "installation_handle": "22222222-2222-4222-8222-222222222222", @@ -7,7 +7,6 @@ "challenge_note": "base64url-no-pad of bytes 0x00..0x1f", "key_id": "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=", "key_id_note": "standard base64 (padded) of 32 bytes of 0xAA", - "app_profile": "buzz-ios-dogfood", "endpoint": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", "relay_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "not_before": 1752620000, @@ -17,8 +16,8 @@ { "name": "enroll", "domain": "buzz.push.enroll.v1", - "transcript": "buzz.push.enroll.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"key_id\":\"qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=\",\"app_profile\":\"buzz-ios-dogfood\",\"endpoint\":\"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20\",\"endpoint_epoch\":1,\"expires_at\":1752624000}", - "sha256": "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270" + "transcript": "buzz.push.enroll.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"key_id\":\"qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=\",\"endpoint\":\"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20\",\"endpoint_epoch\":1,\"expires_at\":1752624000}", + "sha256": "792966649266bfcebdb79de54e8ed3880746f58efd77427a0f43e88c650d79d0" }, { "name": "delegate", diff --git a/crates/buzz-relay/src/handlers/push_lease.rs b/crates/buzz-relay/src/handlers/push_lease.rs index dd63b438c9..4f5cea8aa5 100644 --- a/crates/buzz-relay/src/handlers/push_lease.rs +++ b/crates/buzz-relay/src/handlers/push_lease.rs @@ -14,7 +14,7 @@ use sha2::Digest as _; /// Message kinds that can produce a mobile Activity-inbox notification. /// Generic Nostr notes and non-message workflow/agent events are deliberately -/// excluded from the dogfood MVP. +/// excluded from the pre-release testing MVP. pub(crate) const PUSH_KINDS: &[u64] = &[9, 40_002, 45_001, 45_003]; /// NIP-PL addressable push-lease event kind. @@ -36,7 +36,6 @@ pub struct LeasePlaintext { pub origin: String, pub generation: u64, pub active: bool, - pub app_profile: Option, pub transport: Option, pub endpoint: Option, pub subscriptions: Option>, @@ -58,16 +57,10 @@ pub struct Suppress { pub p_tags_max: u64, } -#[derive(Debug, Clone, Copy)] -pub struct AppProfile<'a> { - pub id: &'a str, - pub transport: &'a str, -} - pub struct LeaseLimits<'a> { pub expected_origin: &'a str, pub author_hex: &'a str, - pub app_profiles: &'a [AppProfile<'a>], + pub transport: &'a str, pub supported_classes: &'a [&'a str], pub push_kinds: &'a [u64], pub max_subscriptions: usize, @@ -168,7 +161,6 @@ pub fn parse_plaintext(input: &str, max_plaintext_len: usize) -> Result) -> Re check_string(&body.origin, limits.max_string_len)?; if !body.active { - if body.app_profile.is_some() - || body.transport.is_some() - || body.endpoint.is_some() - || body.subscriptions.is_some() - { + if body.transport.is_some() || body.endpoint.is_some() || body.subscriptions.is_some() { return Err("inactive lease must use minimal schema".into()); } return Ok(()); } - let app_profile = body.app_profile.as_deref().ok_or("missing app_profile")?; let transport = body.transport.as_deref().ok_or("missing transport")?; let endpoint = body.endpoint.as_deref().ok_or("missing endpoint")?; let subscriptions = body.subscriptions.as_ref().ok_or("missing subscriptions")?; - let advertised = limits - .app_profiles - .iter() - .find(|profile| profile.id == app_profile) - .ok_or("app profile not supported")?; - if transport != advertised.transport { + if transport != limits.transport { return Err("transport mismatch".into()); } if endpoint.is_empty() || endpoint.len() > limits.max_endpoint_len { return Err("invalid endpoint length".into()); } - check_string(app_profile, limits.max_string_len)?; check_string(transport, limits.max_string_len)?; check_string(endpoint, limits.max_endpoint_len)?; if subscriptions.is_empty() || subscriptions.len() > limits.max_subscriptions { @@ -491,10 +472,7 @@ pub async fn accept( let limits = LeaseLimits { expected_origin: &origin, author_hex: &author_hex, - app_profiles: &[AppProfile { - id: "buzz-ios-dogfood", - transport: "apns", - }], + transport: "apns", supported_classes: &["default"], push_kinds: PUSH_KINDS, max_subscriptions: 16, @@ -527,10 +505,6 @@ pub async fn accept( .subscriptions .as_ref() .ok_or_else(|| "active lease is missing subscriptions".to_string())?; - let app_profile = body - .app_profile - .as_deref() - .ok_or_else(|| "active lease is missing app profile".to_string())?; endpoint_hash = sha2::Sha256::digest(endpoint.as_bytes()).to_vec(); let max_class = body_subscriptions .iter() @@ -541,7 +515,6 @@ pub async fn accept( subscriptions = serde_json::to_value(body_subscriptions) .map_err(|_| "invalid subscriptions".to_string())?; Some(buzz_db::push::ActiveLease { - app_profile, endpoint_hash: &endpoint_hash, endpoint_grant: &capability, max_class, @@ -640,7 +613,7 @@ mod tests { assert!(parse_plaintext(top, 1024) .unwrap_err() .contains("duplicate object key: v")); - let nested = r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"e","subscriptions":[{"filter":{"kinds":[9],"kinds":[7],"#p":["aa"]},"class":"default"}]}"##; + let nested = r##"{"v":1,"origin":"o","generation":1,"active":true,"transport":"apns","endpoint":"e","subscriptions":[{"filter":{"kinds":[9],"kinds":[7],"#p":["aa"]},"class":"default"}]}"##; assert!(parse_plaintext(nested, 4096) .unwrap_err() .contains("duplicate object key: kinds")); @@ -658,14 +631,20 @@ mod tests { ); } + #[test] + fn legacy_application_selector_is_rejected() { + let body = r#"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"legacy","transport":"apns","endpoint":"token","subscriptions":[]}"#; + assert_eq!( + parse_plaintext(body, 4096).unwrap_err(), + "unknown field: app_profile" + ); + } + fn limits<'a>() -> LeaseLimits<'a> { LeaseLimits { expected_origin: "o", author_hex: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - app_profiles: &[AppProfile { - id: "p", - transport: "apns", - }], + transport: "apns", supported_classes: &["default"], push_kinds: &[9], max_subscriptions: 4, @@ -696,7 +675,7 @@ mod tests { #[test] fn active_filter_requires_narrowing_and_self_p_tag() { - let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9]},"class":"default"}]}"##, 4096).unwrap(); + let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9]},"class":"default"}]}"##, 4096).unwrap(); assert_eq!( validate_plaintext(&body, &limits()).unwrap_err(), "lease filter not narrowed" @@ -704,8 +683,8 @@ mod tests { } #[test] - fn profile_transport_and_positive_generation_are_enforced() { - let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"fcm","endpoint":"token","subscriptions":[{"filter":{"kinds":[9],"#p":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},"class":"default"}]}"##, 4096).unwrap(); + fn transport_and_positive_generation_are_enforced() { + let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"transport":"fcm","endpoint":"token","subscriptions":[{"filter":{"kinds":[9],"#p":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},"class":"default"}]}"##, 4096).unwrap(); assert_eq!( validate_plaintext(&body, &limits()).unwrap_err(), "transport mismatch" @@ -724,7 +703,7 @@ mod tests { #[test] fn h_uses_its_advertised_limit_not_the_generic_tag_limit() { - let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9],"#h":["123e4567-e89b-42d3-a456-426614174000","123e4567-e89b-42d3-a456-426614174001"]},"class":"default"}]}"##, 4096).unwrap(); + let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9],"#h":["123e4567-e89b-42d3-a456-426614174000","123e4567-e89b-42d3-a456-426614174001"]},"class":"default"}]}"##, 4096).unwrap(); let mut limits = limits(); limits.max_h = 2; limits.max_tag_values = 1; diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index abd3d258e3..d60771b2bc 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -336,7 +336,6 @@ fn push_descriptor( "pubkey": relay_keypair.public_key().to_hex(), "current": true }], - "app_profiles": [{"id": "buzz-ios-dogfood", "transport": "apns"}], "push_kinds": crate::handlers::push_lease::PUSH_KINDS, "h_grammar": "uuid-v4-lowercase", "class_support": {"apns": ["default"]}, @@ -417,7 +416,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st ) { info.supported_extensions .get_or_insert_default() - .push("nip-pl".to_string()); + .push("buzz-push-v1".to_string()); info.push = Some(push); } info diff --git a/deploy/charts/buzz-push-gateway/CHANGELOG.md b/deploy/charts/buzz-push-gateway/CHANGELOG.md index fea2e7c9cf..c91aa5822d 100644 --- a/deploy/charts/buzz-push-gateway/CHANGELOG.md +++ b/deploy/charts/buzz-push-gateway/CHANGELOG.md @@ -1,5 +1,9 @@ # Chart release notes +## 0.4.0 + +- Replace `profiles.dogfood` with `application` and use unprefixed application/APNs environment variables. Requires the matching buzz-push-v1 gateway image and fresh authority store. + ## 0.3.5 Adds optional string-valued `deploymentAnnotations` for Deployment metadata, diff --git a/deploy/charts/buzz-push-gateway/Chart.yaml b/deploy/charts/buzz-push-gateway/Chart.yaml index 7568c3620a..2dabd74e74 100644 --- a/deploy/charts/buzz-push-gateway/Chart.yaml +++ b/deploy/charts/buzz-push-gateway/Chart.yaml @@ -3,6 +3,6 @@ apiVersion: v2 # branches (see docs/push-gateway-deployment.md, "Gateway chart release"). name: buzz-push-gateway description: Public capability-gated APNs last-hop gateway for Buzz -version: 0.3.5 +version: 0.4.0 appVersion: "0.1.0" type: application diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index bee0ec8a41..b9fedc9fc2 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -49,17 +49,17 @@ spec: {{/* Schema bounds this integer; normalize YAML numbers before quote can emit scientific notation. */}} - { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | int64 | 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_ATTEST_APP_ID, value: {{ .Values.application.appAttestAppId | quote }} } + - { name: BUZZ_PUSH_APNS_TOPIC, value: {{ .Values.application.apnsTopic | quote }} } + - { name: BUZZ_PUSH_APNS_ENVIRONMENT, value: {{ .Values.application.apnsEnvironment | quote }} } + - { name: BUZZ_PUSH_APNS_CERT_PATH, value: /run/buzz/apns/identity.pem } {{- range $name := list "DATABASE_URL" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }} - name: {{ $name }} valueFrom: { secretKeyRef: { name: {{ $.Values.existingSecret }}, key: {{ $name }} } } {{- end }} volumeMounts: - { name: app-attest-root, mountPath: /run/buzz/app-attest, readOnly: true } - - { name: apns-dogfood, mountPath: /run/buzz/apns-dogfood, readOnly: true } + - { name: apns, mountPath: /run/buzz/apns, readOnly: true } 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 } @@ -67,8 +67,8 @@ spec: volumes: - name: app-attest-root 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 }] } + - name: apns + secret: { secretName: {{ .Values.application.apnsCert.secretName }}, defaultMode: 0400, items: [{ key: {{ .Values.application.apnsCert.secretKey }}, path: identity.pem }] } {{- 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 5c5433132e..e5b9d11a5a 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -15,9 +15,14 @@ helm template push deploy/charts/buzz-push-gateway >"$out" production_args=( -f deploy/charts/buzz-push-gateway/values-production.yaml --set 'image.digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - --set 'profiles.dogfood.appAttestAppId=REALTEAM.xyz.block.buzz.dogfood.mobile' + --set 'application.appAttestAppId=REALTEAM.com.example.production' --set 'networkPolicy.postgresEgressCidrs[0]=10.42.0.0/16' ) +if helm template push deploy/charts/buzz-push-gateway "${production_args[@]}" >/dev/null 2>&1; then + echo 'expected production values without an APNs topic to fail' >&2 + exit 1 +fi +production_args+=(--set 'application.apnsTopic=com.example.production') helm lint deploy/charts/buzz-push-gateway "${production_args[@]}" >/dev/null helm template push deploy/charts/buzz-push-gateway "${production_args[@]}" >"$production_out" @@ -77,14 +82,14 @@ assert!(j.dig("metadata", "annotations") == { env_names = d.dig("spec", "template", "spec", "containers", 0, "env") .map { |entry| entry["name"] }.to_set required = Set.new(%w[ - DATABASE_URL BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH - BUZZ_PUSH_DOGFOOD_APNS_TOPIC BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID + DATABASE_URL BUZZ_PUSH_APNS_CERT_PATH + BUZZ_PUSH_APNS_TOPIC BUZZ_PUSH_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_GATEWAY_ORIGIN")) assert!(!env_names.any? { |name| name.include?("APP_STORE") }) -apns_volume = d.dig("spec", "template", "spec", "volumes").find { |volume| volume["name"] == "apns-dogfood" } +apns_volume = d.dig("spec", "template", "spec", "volumes").find { |volume| volume["name"] == "apns" } assert!(apns_volume.dig("secret", "defaultMode") == 0o400, apns_volume.inspect) assert!(d.dig("spec", "replicas") >= 2) assert!(!xs.any? { |x| x["kind"] == "HTTPRoute" }) @@ -116,6 +121,8 @@ assert!(!production.any? { |x| x["kind"] == "HTTPRoute" }) production_deployment = production.find { |x| x["kind"] == "Deployment" } production_image = production_deployment.dig("spec", "template", "spec", "containers", 0, "image") assert!(production_image == "ghcr.io/block/buzz-push-gateway@sha256:#{"a" * 64}", production_image.inspect) +production_env = production_deployment.dig("spec", "template", "spec", "containers", 0, "env") +assert!(production_env.find { |env| env["name"] == "BUZZ_PUSH_APNS_TOPIC" }["value"] == "com.example.production") route = YAML.load_stream(File.read(ARGV[2])).compact.find { |x| x["kind"] == "HTTPRoute" } assert!(!route.dig("spec", "parentRefs").empty?) assert!(route.dig("spec", "hostnames") == ["push.example"]) diff --git a/deploy/charts/buzz-push-gateway/values-production.yaml b/deploy/charts/buzz-push-gateway/values-production.yaml index 513a64adf0..a24a7ef108 100644 --- a/deploy/charts/buzz-push-gateway/values-production.yaml +++ b/deploy/charts/buzz-push-gateway/values-production.yaml @@ -5,9 +5,9 @@ image: digest: "" # Optional routing input, required only when httpRoute.enabled=true. gatewayOrigin: "" -profiles: - dogfood: - appAttestAppId: "" +application: + appAttestAppId: "" + apnsTopic: "" httpRoute: # Keep disabled when the platform already routes the configured gateway # origin to this Service. Gateway API users enable it and inject a parentRef. diff --git a/deploy/charts/buzz-push-gateway/values.schema.json b/deploy/charts/buzz-push-gateway/values.schema.json index 5a9befcccf..025cbcb511 100644 --- a/deploy/charts/buzz-push-gateway/values.schema.json +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -21,18 +21,6 @@ "minimum": 1, "maximum": 31536000 }, - "profiles": { - "type": "object", - "additionalProperties": false, - "required": [ - "dogfood" - ], - "properties": { - "dogfood": { - "$ref": "#/$defs/enabledProfile" - } - } - }, "apnsKey": false, "deploymentAnnotations": { "type": "object", @@ -338,16 +326,39 @@ "terminationGracePeriodSeconds": { "type": "integer", "minimum": 60 - } + }, + "application": { + "$ref": "#/$defs/application" + }, + "profiles": false }, "$defs": { - "profileBase": { + "apnsCert": { + "type": "object", + "additionalProperties": false, + "required": [ + "secretName", + "secretKey" + ], + "properties": { + "secretName": { + "type": "string", + "minLength": 1 + }, + "secretKey": { + "type": "string", + "minLength": 1 + } + } + }, + "application": { "type": "object", "additionalProperties": false, "required": [ "appAttestAppId", "apnsTopic", - "apnsEnvironment" + "apnsEnvironment", + "apnsCert" ], "properties": { "appAttestAppId": { @@ -368,43 +379,13 @@ "$ref": "#/$defs/apnsCert" } } - }, - "enabledProfile": { - "allOf": [ - { - "$ref": "#/$defs/profileBase" - }, - { - "required": [ - "apnsCert" - ] - } - ] - }, - "apnsCert": { - "type": "object", - "additionalProperties": false, - "required": [ - "secretName", - "secretKey" - ], - "properties": { - "secretName": { - "type": "string", - "minLength": 1 - }, - "secretKey": { - "type": "string", - "minLength": 1 - } - } } }, "required": [ "replicaCount", "existingSecret", "maxGrantLifetimeSeconds", - "profiles", + "application", "httpRoute", "image", "migration", diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index 2bad78d731..91f6c66a4d 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -23,15 +23,14 @@ migration: # Routing origin used only when httpRoute.enabled=true. Not passed to the binary. gatewayOrigin: "" maxGrantLifetimeSeconds: 2592000 -profiles: - dogfood: - # Production MUST override this with the exact Apple TEAMID.bundle-id. - appAttestAppId: TEAMID.xyz.block.buzz.dogfood.mobile - apnsTopic: xyz.block.buzz.dogfood.mobile - apnsEnvironment: production - apnsCert: - secretName: buzz-push-gateway - secretKey: dogfood-apns-identity.pem +application: + # Production MUST override this with the exact Apple TEAMID.bundle-id. + appAttestAppId: TEAMID.com.example.buzz + apnsTopic: com.example.buzz + apnsEnvironment: production + apnsCert: + secretName: buzz-push-gateway + secretKey: apns-identity.pem appAttestRoot: secretName: buzz-push-gateway secretKey: app-attest-root.pem diff --git a/docs/buzz-push.md b/docs/buzz-push.md new file mode 100644 index 0000000000..34b76655e9 --- /dev/null +++ b/docs/buzz-push.md @@ -0,0 +1,21 @@ +# Buzz push notes + +Push is experimental. These notes capture current decisions; we will write a +stable specification after validating the design through launch and use. +[NIP-PL](nips/NIP-PL.md) is background, not the current Buzz contract. + +- Relays advertise `buzz-push-v1`, never `nip-pl`. Updated clients require it. +- Compatible changes keep the capability name. Incompatible behavior that + clients must distinguish gets a new version. +- Each gateway serves one server-configured Apple application and APNs transport. + Enrollment, transcripts, grants, leases and discovery have no application-profile + selector. The relay advertises transport classes in `class_support`. +- Existing grants and pending enrollment records are not migrated. The new client + uses separate Keychain records and enrolls again. Keep old production push + disabled so released clients never acquire legacy push authority. +- Stop old gateway replicas before cutover. The gateway migration requires an + empty authority store because endpoint fingerprints changed. The relay migration + requires no active legacy leases. Neither migration deletes that state. +- Publish the matching gateway image and chart, update deployment configuration, + then enable the new relay capability. Verify enrollment, delivery, renewal and + revocation on a physical device before production activation. diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 9a46027490..8fce29028b 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -16,31 +16,20 @@ | `BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS` | Maximum delegation capability lifetime (`1..=31536000`). | | `BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS` | Maximum encrypted-token installation lifetime (default 90 days, max one year). Clients must renew before expiry. | | `BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. | -| `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_APP_ATTEST_APP_ID` | Exact server-owned Apple App Attest application identifier (`TEAMID.bundle-id`). | +| `BUZZ_PUSH_APNS_TOPIC` | Server-owned APNs topic. Never accepted from a client. | +| `BUZZ_PUSH_APNS_ENVIRONMENT` | `production` or `sandbox`, selected by deployment configuration. | +| `BUZZ_PUSH_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 current MVP serves the dogfood application identity -(`xyz.block.buzz.dogfood.mobile`). App Attest must cryptographically validate -the configured application ID before enrollment. Assertions and delivery use -the server-owned APNs topic, certificate-backed connection pool, and -environment. No client request or relay grant can supply or override an APNs -topic. +Each gateway serves one configured Apple application. App Attest validates +`application.appAttestAppId` (`TEAMID.bundle-id`) before enrollment. APNs uses +the configured topic, certificate and environment. Clients and relay grants +cannot select or override that identity. Separate applications use separate +gateway deployments and authority databases. -This MVP has exactly one compiled-in application profile, -`buzz-ios-dogfood`. The chart value -`profiles.dogfood.appAttestAppId` is rendered as -`BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID`; the gateway rejects startup when it is -missing or empty. The exact `TEAMID.bundle-id` is environment-owned, -non-secret deployment configuration. The chart's production values file leaves -it empty deliberately so a production renderer must supply it from the GitOps -environment rather than baking a Block team identifier into this repository. -Supporting another application identity requires an explicit code, schema, -chart, credential, and deployment change; this gateway does not currently -select among multiple application profiles. +See [Buzz push notes](buzz-push.md) for the current experimental contract. 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. @@ -56,12 +45,11 @@ The gateway accepts exactly the selected AAGUID and still verifies the pinned Apple root, certificate chain, nonce, application identity, public key, credential ID, and counter. This is not a simulator or attestation bypass. -Set `BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID` to the personal `TEAMID.bundle-id`, -`BUZZ_PUSH_DOGFOOD_APNS_TOPIC` to that same bundle ID, -`BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT=sandbox`, and supply its APNs certificate. -This isolated stack reuses the single `buzz-ios-dogfood` wire profile for its -server-owned personal identity; it does not add a production application profile. -Do not point distributed dogfood clients at this personal gateway. +Set `BUZZ_PUSH_APP_ATTEST_APP_ID` to the personal `TEAMID.bundle-id`, +`BUZZ_PUSH_APNS_TOPIC` to that same bundle ID, +`BUZZ_PUSH_APNS_ENVIRONMENT=sandbox`, and supply its APNs certificate. +This isolated stack uses its own server-configured application identity. +Do not point distributed clients at this personal gateway. Build the mobile client with the personal team and parent bundle ID, its matching `.NotificationService` extension, development APNs/App Attest entitlements, and @@ -156,7 +144,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 APNs certificate/topic/environment is unhealthy. Check `BUZZ_PUSH_DOGFOOD_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_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. | @@ -200,43 +188,17 @@ delivery run only when `BUZZ_PUSH_ENABLED=true`. End-to-end use still requires the client App Attest enrollment/delegation flow to place a gateway-issued opaque capability—not a raw APNs token—into the encrypted relay lease. -## Internal dogfood evaluation and rollback +## Evaluation and rollout -The MVP is ready to enable only when the configured gateway's sole dogfood -profile is configured with its server-owned App Attest app ID, APNs topic, -production certificate identity, and production APNs environment, and only the -selected internal relay deployments set `BUZZ_PUSH_ENABLED=true`. Every iOS -artifact contains the native push bridge and Notification Service Extension, -but the client remains inactive until its current authenticated relay -advertises a fully valid NIP-11 `nip-pl` descriptor. There is no App Store -gateway profile in this MVP. +Enable push only after a physical-device test with the deployment's configured +application identity, APNs topic, certificate and environment. Updated clients +require the NIP-11 `buzz-push-v1` capability. Keep `nip-pl` disabled on production +relays throughout the cutover. -Physical-device validation must use an application whose App Attest identity -and APNs topic match the configured dogfood profile. The current gateway cannot -enroll `xyz.block.buzz.mobile` or another bundle identifier merely by changing -deployment values: adding another identity requires the explicit multi-profile -work described above. - -Dogfood end-to-end release validation starts after this feature reaches `main`: -publish the next immutable `mobile-vX.Y.Z-rc.N` candidate from the exact current -`origin/main` commit, build that tag through the normal Block release pipeline, -and wait for the signed `xyz.block.buzz.dogfood.mobile` artifact to appear in -Mobile Releases/Comp Portal before installing it on a physical device. Verify -APNs delivery, fetched and signature-verified notification content, and -exact-message tap routing against the configured gateway and a push-enabled -internal relay before widening the internal evaluation. - -Before that first candidate, the private dogfood builder's manual signing and -export configuration must map separate distribution -profiles for both `xyz.block.buzz.dogfood.mobile` and -`xyz.block.buzz.dogfood.mobile.NotificationService`; an app-only profile does -not provision the extension. App Store rollout remains off through relay and -gateway deployment configuration until separately approved. -Before enabling rich message presentation, enable Apple's Communication -Notifications capability on the parent dogfood App ID and regenerate its app -provisioning profile. The extension profile does not need that capability. -Apply the same parent-App-ID prerequisite to the eventual App Store rollout; -updating Block Apple portal records is a separately authorized release step. +Build a signed release candidate with both the app and Notification Service +Extension provisioned. Verify APNs delivery, fetched and signature-verified +notification content, and exact-message tap routing. Enable Apple's Communication +Notifications capability on the parent App ID for rich message presentation. For each evaluation cohort, measure relay receipt-to-match, wake queue, relay-to- gateway, and gateway-to-APNs latencies from the histograms above. Track the @@ -332,7 +294,7 @@ the environment's GitOps values; the chart then renders `ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag. `values-production.yaml` remains an intentionally invalid production-input contract: deployment CI must inject the verified image digest, the provisioned -dogfood Apple application identifier and the actual PostgreSQL network. Set +distributed Apple application identifier, its matching APNs topic, and the actual PostgreSQL network. Set `gatewayOrigin` only when enabling the chart’s optional HTTPRoute; it supplies the routing hostname and is not passed to the gateway binary. In an environment with an existing ingress or service mesh route, keep diff --git a/migrations/0060_push_single_application.sql b/migrations/0060_push_single_application.sql new file mode 100644 index 0000000000..8572c09514 --- /dev/null +++ b/migrations/0060_push_single_application.sql @@ -0,0 +1,28 @@ +-- Existing active leases use the retired nip-pl contract. Cut over while push +-- is disabled; do not silently convert or discard their authority. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM push_leases WHERE active) THEN + RAISE EXCEPTION 'buzz-push-v1 requires no active legacy push leases. Retire legacy leases before upgrading.'; + END IF; +END +$$; +ALTER TABLE push_leases DROP COLUMN app_profile; +ALTER TABLE push_leases ADD CHECK ( + (active AND endpoint_hash IS NOT NULL AND endpoint_grant IS NOT NULL AND max_class IS NOT NULL AND subscriptions IS NOT NULL) + OR (NOT active AND endpoint_hash IS NULL AND endpoint_grant IS NULL AND max_class IS NULL AND subscriptions IS NULL) +); +CREATE UNIQUE INDEX push_leases_endpoint_unique + ON push_leases (community_id, author, endpoint_hash) WHERE active; +-- The new capability starts with fresh installation authority. Do not silently +-- retain fingerprints made with the removed application-profile namespace. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM push_gateway_installations) THEN + RAISE EXCEPTION 'buzz-push-v1 requires an empty gateway authority store. Stop old gateway replicas and provision a fresh dedicated database before upgrading.'; + END IF; +END +$$; +ALTER TABLE push_gateway_installations DROP COLUMN app_profile; +CREATE UNIQUE INDEX push_gateway_installations_active_token + ON push_gateway_installations (token_fingerprint) WHERE revoked_at IS NULL; diff --git a/mobile/README.md b/mobile/README.md index eaf82f3a28..b447a7fbe6 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -35,7 +35,7 @@ cd mobile && flutter run --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.exampl Debug builds produced from a git worktree get a unique app identifier keyed to the **worktree directory name** -(`xyz.block.buzz.dogfood.mobile.` on iOS, +(`xyz.block.buzz.dev.mobile.` on iOS, `xyz.block.buzz.mobile.` on Android) plus a display-only branch label in the app name (`Buzz (my-branch)`, or a short SHA when the worktree is detached). Because the identifier follows the directory rather than the @@ -105,7 +105,7 @@ installs are never touched. Every iOS artifact builds and embeds the Notification Service Extension and native push bridge. Runtime activation is fail-closed and scoped to the current -relay. After authenticated connectivity and a fully valid NIP-11 `nip-pl` push +relay. After authenticated connectivity and a fully valid NIP-11 `buzz-push-v1` push descriptor, Buzz independently requests display permission and registers with APNs. Display denial or request failure does not gate the device token, gateway enrollment, or lease publication, so a later user opt-in can display pushes @@ -153,15 +153,14 @@ App Attest application ID, APNs topic and sandbox certificate. Development attestation requires the explicit `personal-dev-app-attest` gateway build feature and `BUZZ_PUSH_APP_ATTEST_ENVIRONMENT=development`; ordinary gateway builds accept production attestation only. See `docs/push-gateway-deployment.md`. -This validates the personal client/relay/gateway integration, not the internally -distributed dogfood artifact. Validate dogfood separately using the signed -internal release and its production gateway configuration. +Validate distributed builds separately using a signed release candidate and +its matching gateway configuration. Parent app identifiers require Apple's Communication Notifications capability and a regenerated app provisioning profile. The Notification Service Extension profile does not require that capability. Enable it on the personal development App ID for local rich-presentation -validation. Enabling it on the Block dogfood and eventual App Store App IDs is +validation. Enabling it on distribution App IDs is a release follow-up and is not performed by this repository change. Without a matching parent profile, source and unit validation still work, but the app cannot be signed for a physical device. diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index dabed2175f..f2a8a66063 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -31,8 +31,6 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { public let endpointGrant: String /// Lowercase SHA-256 digest of the binary APNs device token. public let endpointHash: String - /// Server-owned application identity used for enrollment. - public let appProfile: String /// Monotonic endpoint version bound into the gateway grant. public let endpointEpoch: Int64 /// Monotonic delegation version bound into the gateway grant. @@ -51,7 +49,6 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { installationId: String, endpointGrant: String, endpointHash: String, - appProfile: String, endpointEpoch: Int64, generation: Int64, expiresAt: Int64 @@ -66,7 +63,6 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { self.installationId = installationId self.endpointGrant = endpointGrant self.endpointHash = endpointHash - self.appProfile = appProfile self.endpointEpoch = endpointEpoch self.generation = generation self.expiresAt = expiresAt @@ -78,21 +74,19 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { public protocol BuzzPushEndpointGrantStore { /// Returns durable grants across configured origins without migrating them. func records() throws -> [BuzzPushEndpointGrantRecord] - /// Atomically replaces the grant for its gateway, relay origin, and app profile. + /// Atomically replaces the grant for its gateway and relay origin. func save(_ record: BuzzPushEndpointGrantRecord) throws - /// Loads the exact retry journal for a gateway, relay origin, and app profile. + /// Loads the exact retry journal for a gateway and relay origin. func pendingEnrollment( gatewayOrigin: String, - relayOrigin: String, - appProfile: String + relayOrigin: String ) throws -> BuzzPushPendingEnrollmentRecord? /// Persists the retry journal before any enrollment or delegation mutation. func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws /// Removes a journal only after success or authoritative terminal failure. func removePendingEnrollment( gatewayOrigin: String, - relayOrigin: String, - appProfile: String + relayOrigin: String ) throws } @@ -331,7 +325,6 @@ struct BuzzDCAppAttestProvider: BuzzDevAppAttesting { /// Enrollment and delegation driver for real App Attest and the gated debug bypass. public final class BuzzDevPushEnrollmentDriver { - public static let appProfile = "buzz-ios-dogfood" public static let endpointEpoch: Int64 = 1 private let gatewayBaseURL: URL @@ -418,12 +411,10 @@ public final class BuzzDevPushEnrollmentDriver { let storedRecords = try store.records() let storedForOrigin = storedRecords.first { $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin.text - && $0.appProfile == Self.appProfile } let pendingEnrollment = try store.pendingEnrollment( gatewayOrigin: gatewayOrigin, - relayOrigin: relayOrigin.text, - appProfile: Self.appProfile + relayOrigin: relayOrigin.text ) if let pending = pendingEnrollment, pending.relayPubkey != relayPubkey || pending.endpointHash != endpointHash @@ -441,15 +432,13 @@ public final class BuzzDevPushEnrollmentDriver { } else { try store.removePendingEnrollment( gatewayOrigin: gatewayOrigin, - relayOrigin: relayOrigin.text, - appProfile: Self.appProfile + relayOrigin: relayOrigin.text ) } return try await enroll(deviceToken: deviceToken, relayURL: relayURL) } let newestSharedGrant = storedRecords.filter { $0.gatewayOrigin == gatewayOrigin && $0.relayPubkey == relayPubkey - && $0.appProfile == Self.appProfile && $0.endpointHash == endpointHash && $0.endpointEpoch == Self.endpointEpoch && $0.expiresAt > nowSeconds + 300 }.max { $0.generation < $1.generation } @@ -465,7 +454,6 @@ public final class BuzzDevPushEnrollmentDriver { installationId: try storedForOrigin?.installationId ?? makeInstallationId(), endpointGrant: reusableGrant.endpointGrant, endpointHash: endpointHash, - appProfile: Self.appProfile, endpointEpoch: reusableGrant.endpointEpoch, generation: reusableGrant.generation, expiresAt: reusableGrant.expiresAt @@ -473,8 +461,7 @@ public final class BuzzDevPushEnrollmentDriver { try store.save(record) try store.removePendingEnrollment( gatewayOrigin: gatewayOrigin, - relayOrigin: relayOrigin.text, - appProfile: Self.appProfile + relayOrigin: relayOrigin.text ) return record } @@ -485,7 +472,6 @@ public final class BuzzDevPushEnrollmentDriver { // its final five minutes is renewed by the authenticated delegation. let reusableInstallation = storedRecords.first { record in guard record.gatewayOrigin == gatewayOrigin, - record.appProfile == Self.appProfile, record.endpointHash == endpointHash, record.endpointEpoch == Self.endpointEpoch, record.expiresAt > nowSeconds, @@ -517,7 +503,6 @@ public final class BuzzDevPushEnrollmentDriver { relayPubkey: relayPubkey, endpoint: endpoint, endpointHash: endpointHash, - appProfile: Self.appProfile, expiresAt: expiresAt, installationId: try storedForOrigin?.installationId ?? makeInstallationId(), gatewayInstallationHandle: existing.uuidString.lowercased(), @@ -533,7 +518,6 @@ public final class BuzzDevPushEnrollmentDriver { challengeId: enrollmentChallenge.id, challenge: enrollmentChallenge.value, keyId: preparedAttestation.keyId, - appProfile: Self.appProfile, endpoint: endpoint, endpointEpoch: Self.endpointEpoch, expiresAt: expiresAt @@ -551,7 +535,6 @@ public final class BuzzDevPushEnrollmentDriver { relayPubkey: relayPubkey, endpoint: endpoint, endpointHash: endpointHash, - appProfile: Self.appProfile, expiresAt: expiresAt, installationId: try storedForOrigin?.installationId ?? makeInstallationId(), challengeId: enrollmentChallenge.id.uuidString.lowercased(), @@ -599,8 +582,7 @@ public final class BuzzDevPushEnrollmentDriver { // request's failure propagates instead of recursively retrying forever. try store.removePendingEnrollment( gatewayOrigin: gatewayOrigin, - relayOrigin: pending.relayOrigin, - appProfile: pending.appProfile + relayOrigin: pending.relayOrigin ) return nil } @@ -649,7 +631,6 @@ public final class BuzzDevPushEnrollmentDriver { relayPubkey: pending.relayPubkey, endpoint: pending.endpoint, endpointHash: pending.endpointHash, - appProfile: pending.appProfile, expiresAt: pending.expiresAt, installationId: pending.installationId, gatewayInstallationHandle: installation.uuidString.lowercased(), @@ -667,7 +648,7 @@ public final class BuzzDevPushEnrollmentDriver { storedRecords .filter { $0.gatewayOrigin == gatewayOrigin && $0.gatewayInstallationHandle == installationHandle - && $0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile + && $0.relayPubkey == relayPubkey } .map(\.generation) .max() @@ -688,7 +669,6 @@ public final class BuzzDevPushEnrollmentDriver { relayPubkey: pending.relayPubkey, endpoint: pending.endpoint, endpointHash: pending.endpointHash, - appProfile: pending.appProfile, expiresAt: pending.expiresAt, installationId: pending.installationId, gatewayInstallationHandle: installationHandle, @@ -741,7 +721,6 @@ public final class BuzzDevPushEnrollmentDriver { installationId: pending.installationId, endpointGrant: endpointGrant, endpointHash: pending.endpointHash, - appProfile: Self.appProfile, endpointEpoch: Self.endpointEpoch, generation: generation, expiresAt: pending.expiresAt @@ -749,8 +728,7 @@ public final class BuzzDevPushEnrollmentDriver { try store.save(record) try store.removePendingEnrollment( gatewayOrigin: gatewayOrigin, - relayOrigin: pending.relayOrigin, - appProfile: Self.appProfile + relayOrigin: pending.relayOrigin ) return record } @@ -796,7 +774,6 @@ public final class BuzzDevPushEnrollmentDriver { challenge: challenge.value, keyId: attestation.keyId, attestation: attestation.attestation, - appProfile: Self.appProfile, endpoint: endpoint, endpointEpoch: Self.endpointEpoch, expiresAt: expiresAt @@ -1006,7 +983,6 @@ private struct InstallationRequest: Encodable { let challenge: String let keyId: String let attestation: String - let appProfile: String let endpoint: String let endpointEpoch: Int64 let expiresAt: Int64 @@ -1016,7 +992,6 @@ private struct InstallationRequest: Encodable { case challenge case keyId = "key_id" case attestation - case appProfile = "app_profile" case endpoint case endpointEpoch = "endpoint_epoch" case expiresAt = "expires_at" diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift index 8143b4385d..84ab451ea4 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift @@ -12,8 +12,6 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { public let endpoint: String /// Lowercase SHA-256 digest of the binary APNs device token. public let endpointHash: String - /// Server-owned application identity used for enrollment. - public let appProfile: String /// Unix expiration time of the installation or delegation authority. public let expiresAt: Int64 /// Unlinkable per-relay lease address, distinct from the gateway handle. @@ -38,7 +36,6 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { relayPubkey: String, endpoint: String, endpointHash: String, - appProfile: String, expiresAt: Int64, installationId: String, gatewayInstallationHandle: String? = nil, @@ -53,7 +50,6 @@ public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { self.relayPubkey = relayPubkey self.endpoint = endpoint self.endpointHash = endpointHash - self.appProfile = appProfile self.expiresAt = expiresAt self.installationId = installationId self.gatewayInstallationHandle = gatewayInstallationHandle diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift index c33c5fad28..a87f0e1d88 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift @@ -2,7 +2,7 @@ import Foundation /// Errors thrown by the canonical transcript encoder. public enum BuzzPushTranscriptError: Error, Equatable { - /// A string field contained non-ASCII scalars. NIP-PL admits only ASCII + /// A string field contained non-ASCII scalars. Buzz push admits only ASCII /// authority-bearing strings; rather than guess at UTF-8-vs-escaping /// behavior we fail closed. case nonASCIIInput(field: String) @@ -10,9 +10,9 @@ public enum BuzzPushTranscriptError: Error, Equatable { case invalidGatewayOrigin } -/// Canonical NIP-PL App Attest transcript encoder. +/// Canonical Buzz push App Attest transcript encoder. /// -/// NIP-PL ("Exact App Attest transcript construction") pins the exact bytes +/// Buzz push pins the exact bytes /// every App Attest operation signs: /// /// + "\n" + @@ -41,7 +41,7 @@ public enum BuzzPushTranscript { public static let revokeDelegationDomain = "buzz.push.revoke-delegation.v1" public static let revokeInstallationDomain = "buzz.push.revoke-installation.v1" - /// Wire version pinned by NIP-PL. Every transcript carries `"v":1`. + /// Wire version for buzz-push-v1. Every transcript carries `"v":1`. public static let wireVersion: Int64 = 1 /// Origin registered by NIP-PL v1 for every App Attest transcript audience. @@ -56,7 +56,6 @@ public enum BuzzPushTranscript { challengeId: UUID, challenge: String, keyId: String, - appProfile: String, endpoint: String, endpointEpoch: Int64, expiresAt: Int64 @@ -67,7 +66,6 @@ public enum BuzzPushTranscript { o.uuid("challenge_id", challengeId) try o.string("challenge", challenge, field: "challenge") try o.string("key_id", keyId, field: "key_id") - try o.string("app_profile", appProfile, field: "app_profile") try o.string("endpoint", endpoint, field: "endpoint") o.int("endpoint_epoch", endpointEpoch) o.int("expires_at", expiresAt) diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 2edc513ae3..5bcd37868c 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -82,7 +82,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { XCTAssertEqual(body["challenge"] as? String, Self.challenge) XCTAssertEqual(body["key_id"] as? String, Self.keyId) XCTAssertEqual(body["attestation"] as? String, Self.attestation) - XCTAssertEqual(body["app_profile"] as? String, "buzz-ios-dogfood") + XCTAssertNil(body["app_profile"]) return Self.response( request, status: 201, @@ -127,7 +127,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { try assertMatchesVector( "enroll", actual: appAttest.clientData[0], - expectedSHA256: "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270", + expectedSHA256: "792966649266bfcebdb79de54e8ed3880746f58efd77427a0f43e88c650d79d0", fixture: makeFixtureTranscript( name: "enroll", replacements: [] @@ -156,7 +156,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { installationId: Self.installationId, endpointGrant: "opaque-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), - appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 1, expiresAt: Self.expiresAt @@ -452,7 +451,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testLegacyGrantWithoutGatewayOriginIsRejected() throws { let data = Data( - #"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"# + #"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"# .utf8 ) @@ -461,7 +460,7 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { func testGrantWithoutAppAttestKeyIsRejected() throws { let data = Data( - #"{"gatewayOrigin":"https://push.example","relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"# + #"{"gatewayOrigin":"https://push.example","relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"# .utf8 ) @@ -477,7 +476,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { installationId: Self.installationId, endpointGrant: "other-gateway-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), - appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 1, expiresAt: Self.expiresAt @@ -751,7 +749,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { installationId: String(repeating: "f", count: 32), endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), - appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 4, expiresAt: Self.expiresAt @@ -798,7 +795,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { installationId: String(repeating: "f", count: 32), endpointGrant: "first-relay-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), - appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 7, expiresAt: Self.expiresAt @@ -870,7 +866,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), - appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 7, expiresAt: Self.now + 300 @@ -976,7 +971,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), - appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 1, expiresAt: Self.expiresAt @@ -1019,7 +1013,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), - appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 1, expiresAt: Self.expiresAt @@ -1058,7 +1051,6 @@ final class BuzzDevPushEnrollmentDriverTests: XCTestCase { installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), - appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 1, expiresAt: Self.expiresAt @@ -1265,31 +1257,26 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { } saved.removeAll { $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin - && $0.appProfile == record.appProfile } saved.append(record) } func pendingEnrollment( gatewayOrigin: String, - relayOrigin: String, - appProfile: String + relayOrigin: String ) throws -> BuzzPushPendingEnrollmentRecord? { pending.first { $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin - && $0.appProfile == appProfile } } func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws { pending.removeAll { $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin - && $0.appProfile == record.appProfile } pending.append(record) } func removePendingEnrollment( gatewayOrigin: String, - relayOrigin: String, - appProfile: String + relayOrigin: String ) throws { if pendingRemoveFailuresRemaining > 0 { pendingRemoveFailuresRemaining -= 1 @@ -1297,7 +1284,6 @@ private final class MemoryGrantStore: BuzzPushEndpointGrantStore { } pending.removeAll { $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin - && $0.appProfile == appProfile } } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift index c08fe9c82c..65e4d01f20 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift @@ -46,7 +46,6 @@ final class BuzzPushTranscriptTests: XCTestCase { static let installationHandle = UUID(uuidString: "22222222-2222-4222-8222-222222222222")! static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=" - static let appProfile = "buzz-ios-dogfood" static let endpoint = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" static let relayPubkey = String(repeating: "a", count: 64) static let notBefore: Int64 = 1_752_620_000 @@ -75,7 +74,6 @@ final class BuzzPushTranscriptTests: XCTestCase { challengeId: Self.challengeId, challenge: Self.challenge, keyId: Self.keyId, - appProfile: Self.appProfile, endpoint: Self.endpoint, endpointEpoch: 1, expiresAt: Self.expiresAt diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 8660e4e3ed..3b2ba6e454 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -6,7 +6,7 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG // without patching tracked files by writing // `mobile/ios/Flutter/AppOverrides.xcconfig` containing // `BUNDLE_IDENTIFIER = your.app.id` (gitignored). -BUNDLE_IDENTIFIER = xyz.block.buzz.dogfood.mobile +BUNDLE_IDENTIFIER = xyz.block.buzz.dev.mobile APP_DISPLAY_NAME = Buzz BUZZ_DEVELOPMENT_TEAM = JMTDPW9CG3 diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index 89112e7470..c4c80c1298 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -6,8 +6,8 @@ import Security /// UserDefaults or logs. Dart can read the closed record through the push bridge. final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { private static let service = "buzz.push.endpoint-grants" - private static let recordsAccount = "v2" - private static let pendingAccount = "pending-v2" + private static let recordsAccount = "records-buzz-push-v1" + private static let pendingAccount = "pending-buzz-push-v1" private let accessGroup: String? @@ -40,7 +40,6 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { var all = try records() all.removeAll { $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin - && $0.appProfile == record.appProfile } all.append(record) try replaceValue(all, account: Self.recordsAccount) @@ -48,12 +47,10 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { func pendingEnrollment( gatewayOrigin: String, - relayOrigin: String, - appProfile: String + relayOrigin: String ) throws -> BuzzPushPendingEnrollmentRecord? { try pendingEnrollments().first { $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin - && $0.appProfile == appProfile } } @@ -61,7 +58,6 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { var all = try pendingEnrollments() all.removeAll { $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin - && $0.appProfile == record.appProfile } all.append(record) try replaceValue(all, account: Self.pendingAccount) @@ -69,13 +65,11 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { func removePendingEnrollment( gatewayOrigin: String, - relayOrigin: String, - appProfile: String + relayOrigin: String ) throws { var all = try pendingEnrollments() all.removeAll { $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin - && $0.appProfile == appProfile } try replaceValue(all, account: Self.pendingAccount) } @@ -153,7 +147,6 @@ extension BuzzPushEndpointGrantRecord { "installationId": installationId, "endpointGrant": endpointGrant, "endpointHash": endpointHash, - "appProfile": appProfile, "endpointEpoch": endpointEpoch, "generation": generation, "expiresAt": expiresAt, diff --git a/mobile/ios/Runner/PushSnapshotBridge.swift b/mobile/ios/Runner/PushSnapshotBridge.swift index d01a9e2e68..e6598ae3f0 100644 --- a/mobile/ios/Runner/PushSnapshotBridge.swift +++ b/mobile/ios/Runner/PushSnapshotBridge.swift @@ -227,8 +227,7 @@ final class BuzzPushSnapshotBridge { return nil } return grants.filter { - $0.appProfile == BuzzDevPushEnrollmentDriver.appProfile - && BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin + BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin }.max { $0.generation < $1.generation }?.relayMetadataPubkey diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index 40b3cadee8..940088933c 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -413,14 +413,13 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { wait(for: [completed], timeout: 1) } - func testMetadataAuthorityUsesCurrentAppProfileForMatchingRelay() { - let correctProfile = grant( - appProfile: BuzzDevPushEnrollmentDriver.appProfile, + func testMetadataAuthorityUsesNewestGrantForMatchingRelay() { + let correctRelay = grant( generation: 2, metadataPubkey: String(repeating: "a", count: 64) ) - let wrongProfile = grant( - appProfile: "other-profile", + let wrongRelay = grant( + relayOrigin: "https://other.example", generation: 99, metadataPubkey: String(repeating: "b", count: 64) ) @@ -428,27 +427,28 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { XCTAssertEqual( BuzzPushSnapshotBridge.relayMetadataPubkey( relayURL: "wss://relay.example/", - grants: [wrongProfile, correctProfile] + grants: [wrongRelay, correctRelay, grant( + generation: 1, metadataPubkey: String(repeating: "d", count: 64) + )] ), - correctProfile.relayMetadataPubkey + correctRelay.relayMetadataPubkey ) } private func grant( - appProfile: String, + relayOrigin: String = "https://relay.example", generation: Int64, metadataPubkey: String ) -> BuzzPushEndpointGrantRecord { BuzzPushEndpointGrantRecord( gatewayOrigin: "https://push.example", - relayOrigin: "https://relay.example", + relayOrigin: relayOrigin, relayPubkey: String(repeating: "c", count: 64), relayMetadataPubkey: metadataPubkey, appAttestKeyId: Data(repeating: 0xAA, count: 32).base64EncodedString(), installationId: "installation", endpointGrant: "opaque-grant", endpointHash: String(repeating: "d", count: 64), - appProfile: appProfile, endpointEpoch: 1, generation: generation, expiresAt: 1_900_000_000 diff --git a/mobile/lib/features/age_gate/age_signal_provider.dart b/mobile/lib/features/age_gate/age_signal_provider.dart index 09b8ea47b5..59c262713d 100644 --- a/mobile/lib/features/age_gate/age_signal_provider.dart +++ b/mobile/lib/features/age_gate/age_signal_provider.dart @@ -80,14 +80,14 @@ class AgeSignalNotifier extends Notifier { } } -/// Explicit dogfood opt-in. Production builds keep enforcement disabled. +/// Explicit pre-release testing opt-in. Production builds keep enforcement disabled. const ageGatingEnabled = bool.fromEnvironment('BUZZ_AGE_GATING_ENABLED'); final ageSignalProvider = NotifierProvider( ageGatingEnabled ? AgeSignalNotifier.new : _DisabledAgeSignalNotifier.new, ); -// Remains disabled until the fail-open implementation is validated in dogfood. +// Remains disabled until the fail-open implementation is validated in pre-release testing. class _DisabledAgeSignalNotifier extends AgeSignalNotifier { @override Future request() async {} diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index f1e0cdef2b..d16084c3ee 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -134,11 +134,7 @@ Future _deactivateCommunityPushLease( canonicalBuzzPushRelayHttpUrl(community.relayUrl), ); final matchingGrant = (await readBuzzPushEndpointGrants()) - .where( - (grant) => - grant.relayOrigin == descriptor.origin && - grant.appProfile == buzzDevPushAppProfile, - ) + .where((grant) => grant.relayOrigin == descriptor.origin) .firstOrNull; if (matchingGrant == null) { throw StateError('No endpoint grant exists for push lease tombstone.'); diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index a66bd64029..26d69538c5 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -11,7 +11,6 @@ import 'push_bridge.dart'; import 'push_subscription.dart'; const buzzPushLeaseKind = 30350; -const buzzDevPushAppProfile = 'buzz-ios-dogfood'; const buzzPushTransport = 'apns'; const _maxSafeJsonInteger = 9007199254740991; const _maxLeaseLifetimeSeconds = 2592000; @@ -48,8 +47,8 @@ class BuzzPushLeaseDescriptor { information['supported_extensions'], name: 'supported_extensions', ); - if (!extensions.contains('nip-pl')) { - throw const FormatException('NIP-11 does not advertise nip-pl'); + if (!extensions.contains('buzz-push-v1')) { + throw const FormatException('NIP-11 does not advertise buzz-push-v1'); } final push = _stringMap(information['push'], name: 'push'); @@ -58,7 +57,6 @@ class BuzzPushLeaseDescriptor { required: const { 'origin', 'keys', - 'app_profiles', 'push_kinds', 'h_grammar', 'class_support', @@ -67,7 +65,6 @@ class BuzzPushLeaseDescriptor { allowed: const { 'origin', 'keys', - 'app_profiles', 'push_kinds', 'h_grammar', 'class_support', @@ -109,31 +106,7 @@ class BuzzPushLeaseDescriptor { } final currentKey = currentKeys.single; - final profiles = _mapList(push['app_profiles'], name: 'app_profiles'); - final profileIds = {}; - String? transport; - for (final profile in profiles) { - _requireExactKeys( - profile, - required: const {'id', 'transport'}, - allowed: const {'id', 'transport'}, - name: 'app profile', - ); - final id = _nonEmptyString(profile['id'], name: 'app profile id'); - if (!profileIds.add(id)) { - throw FormatException('Duplicate app profile id: $id'); - } - final candidate = _nonEmptyString( - profile['transport'], - name: 'app profile transport', - ); - if (id == buzzDevPushAppProfile) transport = candidate; - } - if (transport != buzzPushTransport) { - throw const FormatException( - 'NIP-11 does not advertise the dogfood APNs profile', - ); - } + const transport = buzzPushTransport; final pushKinds = _intList(push['push_kinds'], name: 'push_kinds'); if (!buzzPushEligibleKinds.every(pushKinds.contains)) { @@ -214,7 +187,7 @@ class BuzzPushLeaseDescriptor { origin: origin, executorKeyId: currentKey['id'] as String, executorPubkey: currentKey['pubkey'] as String, - transport: transport!, + transport: transport, maxLeaseTtlSeconds: maxLeaseTtl, maxContentLength: limitation['max_content_len'] as int, maxPlaintextLength: limitation['max_plaintext_len'] as int, @@ -327,7 +300,6 @@ Future publishBuzzDevPushLease({ final plaintextMap = { 'v': 1, 'origin': descriptor.origin, - 'app_profile': grant.appProfile, 'transport': descriptor.transport, 'endpoint': grant.endpointGrant, 'generation': effectiveLeaseGeneration, @@ -501,9 +473,6 @@ void _validateGrant( 'Stored endpoint grant is delegated to a different relay key', ); } - if (grant.appProfile != buzzDevPushAppProfile) { - throw const FormatException('Endpoint grant is not for buzz-ios-dogfood'); - } if (grant.endpointGrant.isEmpty || utf8.encode(grant.endpointGrant).length > descriptor.maxEndpointLength) { throw const FormatException( diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 6500301162..49d2c96cde 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -157,7 +157,6 @@ class BuzzPushEndpointGrant { final String installationId; final String endpointGrant; final String endpointHash; - final String appProfile; final int endpointEpoch; final int generation; final int expiresAt; @@ -168,7 +167,6 @@ class BuzzPushEndpointGrant { required this.installationId, required this.endpointGrant, required this.endpointHash, - required this.appProfile, required this.endpointEpoch, required this.generation, required this.expiresAt, @@ -182,7 +180,6 @@ class BuzzPushEndpointGrant { installationId: map['installationId'] as String, endpointGrant: map['endpointGrant'] as String, endpointHash: map['endpointHash'] as String, - appProfile: map['appProfile'] as String, endpointEpoch: map['endpointEpoch'] as int, generation: generation, expiresAt: map['expiresAt'] as int, diff --git a/mobile/lib/shared/push/push_lease_revocation_outbox.dart b/mobile/lib/shared/push/push_lease_revocation_outbox.dart index 3ba44e1757..4dcff817be 100644 --- a/mobile/lib/shared/push/push_lease_revocation_outbox.dart +++ b/mobile/lib/shared/push/push_lease_revocation_outbox.dart @@ -292,11 +292,7 @@ class BuzzPushLeaseRevocationOutbox { final relayOrigin = canonicalBuzzPushRelayOrigin(relayUrl); final grants = await (readGrants ?? readBuzzPushEndpointGrants)(); final matching = grants - .where( - (grant) => - grant.relayOrigin == relayOrigin && - grant.appProfile == buzzDevPushAppProfile, - ) + .where((grant) => grant.relayOrigin == relayOrigin) .toList(); if (matching.length != 1) { throw StateError( diff --git a/mobile/test/features/age_gate/README.md b/mobile/test/features/age_gate/README.md index b8fa485e6f..d14ebd26dc 100644 --- a/mobile/test/features/age_gate/README.md +++ b/mobile/test/features/age_gate/README.md @@ -9,7 +9,7 @@ blocking retry or restart flow. Production enforcement remains disabled. Dogfood builds can opt in with the Flutter define `BUZZ_AGE_GATING_ENABLED=true`. This enables the same notifier used by the native-channel and app tests. No real-device build is a PR prerequisite; -actual OS prompts, signing, and account behavior will be checked in dogfood. +actual OS prompts, signing, and account behavior will be checked in pre-release testing. ## Automated evidence @@ -33,11 +33,11 @@ actual OS prompts, signing, and account behavior will be checked in dogfood. presentation. Process exit releases restriction without cleanup. Missing, stale, and inaccessible lock files cannot establish a restriction. -Run the Flutter age-gate suite both with and without the dogfood define. A test +Run the Flutter age-gate suite both with and without the pre-release testing define. A test must fail if pending/error becomes restricted or an expired request can restrict. The implementation was mutation-tested for pending and error blocking. An already-running platform consent dialog cannot be proven dismissible by Dart -unit tests. That behavior remains part of dogfood validation before general +unit tests. That behavior remains part of pre-release testing validation before general re-enablement. Old releases that permanently saved push opt-out preferences did not record their cause; this change does not guess which opt-outs to reverse. diff --git a/mobile/test/features/age_gate/age_signal_provider_test.dart b/mobile/test/features/age_gate/age_signal_provider_test.dart index 5444cd0e34..688475fd03 100644 --- a/mobile/test/features/age_gate/age_signal_provider_test.dart +++ b/mobile/test/features/age_gate/age_signal_provider_test.dart @@ -22,22 +22,25 @@ void main() { return container; } - test('production provider respects the explicit dogfood opt-in', () async { - var calls = 0; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(ageSignalChannel, (_) async { - calls += 1; - return {'status': 'signal', 'ageUpper': 17}; - }); - final container = ProviderContainer(); - addTearDown(container.dispose); - await container.read(ageSignalProvider.notifier).request(); - expect( - container.read(ageSignalProvider), - ageGatingEnabled ? AgeSignalState.restricted : AgeSignalState.allowed, - ); - expect(calls, ageGatingEnabled ? 1 : 0); - }); + test( + 'production provider respects the explicit pre-release testing opt-in', + () async { + var calls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ageSignalChannel, (_) async { + calls += 1; + return {'status': 'signal', 'ageUpper': 17}; + }); + final container = ProviderContainer(); + addTearDown(container.dispose); + await container.read(ageSignalProvider.notifier).request(); + expect( + container.read(ageSignalProvider), + ageGatingEnabled ? AgeSignalState.restricted : AgeSignalState.allowed, + ); + expect(calls, ageGatingEnabled ? 1 : 0); + }, + ); for (final upper in [-100, -1, 0, 1, 12, 16, 17, 18, 19, 120, 999999]) { test('native inclusive upper bound $upper', () async { diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart index e1fe5c3c37..1fd979a3b3 100644 --- a/mobile/test/shared/push/dev_push_lease_test.dart +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -22,6 +22,15 @@ void main() { final grant = _grant(relay.public); final now = DateTime.fromMillisecondsSinceEpoch(1752620000 * 1000); + test('legacy capability does not enable Buzz push', () { + final information = _descriptorJson(relay.public); + information['supported_extensions'] = ['nip-pl']; + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsFormatException, + ); + }); + test('publishes strict kind-30350 lease and waits for accepted OK', () async { Map? submitted; final publication = await publishBuzzDevPushLease( @@ -74,7 +83,6 @@ void main() { expect(jsonDecode(plaintext), { 'v': 1, 'origin': 'wss://tenant.example:8443', - 'app_profile': 'buzz-ios-dogfood', 'transport': 'apns', 'endpoint': 'opaque-grant', 'generation': 7, @@ -355,7 +363,6 @@ void main() { for (final field in [ 'origin', 'keys', - 'app_profiles', 'push_kinds', 'h_grammar', 'class_support', @@ -499,15 +506,12 @@ BuzzPushLeaseDescriptor _descriptor(String relayPubkey) => BuzzPushLeaseDescriptor.fromRelayInformation(_descriptorJson(relayPubkey)); Map _descriptorJson(String relayPubkey) => { - 'supported_extensions': ['nip-er', 'nip-pl'], + 'supported_extensions': ['nip-er', 'buzz-push-v1'], 'push': { 'origin': 'wss://tenant.example:8443', 'keys': [ {'id': 'relay-v1', 'pubkey': relayPubkey, 'current': true}, ], - 'app_profiles': [ - {'id': 'buzz-ios-dogfood', 'transport': 'apns'}, - ], 'push_kinds': [9, 40002, 45001, 45003], 'h_grammar': 'uuid-v4-lowercase', 'class_support': { @@ -536,7 +540,6 @@ BuzzPushEndpointGrant _grant(String relayPubkey) => BuzzPushEndpointGrant( installationId: 'c' * 32, endpointGrant: 'opaque-grant', endpointHash: 'd' * 64, - appProfile: 'buzz-ios-dogfood', endpointEpoch: 1, generation: 1, expiresAt: 1756212000, diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 9e87b7bf28..1c76b9a69b 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -203,7 +203,6 @@ void main() { expect(grants.single.installationId, 'c' * 32); expect(grants.single.endpointGrant, 'opaque-grant'); expect(grants.single.endpointHash, 'b' * 64); - expect(grants.single.appProfile, 'buzz-ios-dogfood'); expect(grants.single.endpointEpoch, 1); expect(grants.single.generation, 1); expect(grants.single.expiresAt, 1752624000); @@ -457,7 +456,6 @@ Map _grantMap(String endpointGrant) => { 'installationId': 'c' * 32, 'endpointGrant': endpointGrant, 'endpointHash': 'b' * 64, - 'appProfile': 'buzz-ios-dogfood', 'endpointEpoch': 1, 'generation': 1, 'expiresAt': 1752624000, diff --git a/mobile/test/shared/push/push_lease_revocation_outbox_test.dart b/mobile/test/shared/push/push_lease_revocation_outbox_test.dart index bfc6dd6bff..12eb9cfe25 100644 --- a/mobile/test/shared/push/push_lease_revocation_outbox_test.dart +++ b/mobile/test/shared/push/push_lease_revocation_outbox_test.dart @@ -302,7 +302,6 @@ BuzzPushEndpointGrant _grant({required int expiresAt}) => BuzzPushEndpointGrant( installationId: '0' * 32, endpointGrant: 'opaque', endpointHash: 'b' * 64, - appProfile: 'buzz-ios-dogfood', endpointEpoch: 1, generation: 1, expiresAt: expiresAt, diff --git a/schema/tables/public/push_gateway_installations.sql b/schema/tables/public/push_gateway_installations.sql index ad847f4712..f8f1f85a1e 100644 --- a/schema/tables/public/push_gateway_installations.sql +++ b/schema/tables/public/push_gateway_installations.sql @@ -3,7 +3,6 @@ CREATE TABLE push_gateway_installations ( app_attest_key_id BYTEA NOT NULL CHECK (octet_length(app_attest_key_id) BETWEEN 1 AND 128), app_attest_public_key BYTEA NOT NULL CHECK (octet_length(app_attest_public_key) BETWEEN 33 AND 256), assertion_counter BIGINT NOT NULL CHECK (assertion_counter BETWEEN 0 AND 4294967295), - app_profile TEXT NOT NULL CHECK (app_profile = 'buzz-ios-dogfood'), token_ciphertext BYTEA NOT NULL CHECK (octet_length(token_ciphertext) BETWEEN 1 AND 2048), token_fingerprint BYTEA NOT NULL CHECK (length(token_fingerprint) = 32), endpoint_epoch BIGINT NOT NULL CHECK (endpoint_epoch > 0), @@ -14,6 +13,6 @@ CREATE TABLE push_gateway_installations ( ); CREATE UNIQUE INDEX push_gateway_installations_active_app_attest_key ON push_gateway_installations (app_attest_key_id) WHERE revoked_at IS NULL; -CREATE UNIQUE INDEX push_gateway_installations_active_profile_token - ON push_gateway_installations (app_profile, token_fingerprint) WHERE revoked_at IS NULL; +CREATE UNIQUE INDEX push_gateway_installations_active_token + ON push_gateway_installations (token_fingerprint) WHERE revoked_at IS NULL; CREATE INDEX push_gateway_installations_expiry ON push_gateway_installations (expires_at) WHERE revoked_at IS NULL; diff --git a/schema/tables/public/push_leases.sql b/schema/tables/public/push_leases.sql index fed2e0756a..13abbac47c 100644 --- a/schema/tables/public/push_leases.sql +++ b/schema/tables/public/push_leases.sql @@ -9,7 +9,6 @@ CREATE TABLE push_leases ( generation BIGINT NOT NULL CHECK (generation > 0), active BOOLEAN NOT NULL, endpoint_enabled BOOLEAN NOT NULL DEFAULT true, - app_profile TEXT, endpoint_hash BYTEA CHECK (endpoint_hash IS NULL OR length(endpoint_hash) = 32), endpoint_grant TEXT, max_class TEXT CHECK (max_class IS NULL OR max_class IN ('silent','default','time_sensitive','urgent')), @@ -18,10 +17,10 @@ CREATE TABLE push_leases ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (community_id, author, installation_id), UNIQUE (community_id, source_event_id), - CHECK ((active AND app_profile IS NOT NULL AND endpoint_hash IS NOT NULL AND endpoint_grant IS NOT NULL AND max_class IS NOT NULL AND subscriptions IS NOT NULL) - OR (NOT active AND app_profile IS NULL AND endpoint_hash IS NULL AND endpoint_grant IS NULL AND max_class IS NULL AND subscriptions IS NULL)) + CHECK ((active AND endpoint_hash IS NOT NULL AND endpoint_grant IS NOT NULL AND max_class IS NOT NULL AND subscriptions IS NOT NULL) + OR (NOT active AND endpoint_hash IS NULL AND endpoint_grant IS NULL AND max_class IS NULL AND subscriptions IS NULL)) ); CREATE UNIQUE INDEX push_leases_endpoint_unique - ON push_leases (community_id, author, app_profile, endpoint_hash) + ON push_leases (community_id, author, endpoint_hash) WHERE active; CREATE INDEX push_leases_expiry ON push_leases (community_id, expires_at) WHERE active; diff --git a/scripts/mobile-worktree-clean.sh b/scripts/mobile-worktree-clean.sh index 644737b7e4..9882ab4df4 100755 --- a/scripts/mobile-worktree-clean.sh +++ b/scripts/mobile-worktree-clean.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash # Uninstalls stale worktree-suffixed Buzz debug builds from booted iOS # simulators and connected Android devices/emulators. Unsuffixed app installs -# (`xyz.block.buzz.dogfood.mobile` and `xyz.block.buzz.mobile`) are never -# touched. Only identifiers with a worktree suffix appended after the dogfood +# (`xyz.block.buzz.dev.mobile` and `xyz.block.buzz.mobile`) are never +# touched. Only identifiers with a worktree suffix appended after the development # or production id are matched. Run `just mobile-clean` (or this script # directly); pass --dry-run to list what would be removed without uninstalling. set -euo pipefail -ios_prefix="xyz.block.buzz.dogfood.mobile." +ios_prefix="xyz.block.buzz.dev.mobile." android_prefix="xyz.block.buzz.mobile." dry_run=0 diff --git a/scripts/mobile-worktree-overrides.sh b/scripts/mobile-worktree-overrides.sh index e43ef1e279..840c34864d 100755 --- a/scripts/mobile-worktree-overrides.sh +++ b/scripts/mobile-worktree-overrides.sh @@ -75,7 +75,7 @@ case "$android_slug" in [0-9]*) android_slug="w_$android_slug" ;; esac -ios_bundle_id="xyz.block.buzz.dogfood.mobile.${ios_slug}" +ios_bundle_id="xyz.block.buzz.dev.mobile.${ios_slug}" android_app_name="${BUZZ_ANDROID_DEBUG_APP_NAME:-Buzz (${label})}" android_suffix="${BUZZ_ANDROID_DEBUG_ID_SUFFIX:-.${android_slug}}" diff --git a/scripts/test-mobile-worktree-overrides.sh b/scripts/test-mobile-worktree-overrides.sh index 70637f5225..51fa3b9c1a 100755 --- a/scripts/test-mobile-worktree-overrides.sh +++ b/scripts/test-mobile-worktree-overrides.sh @@ -64,7 +64,7 @@ out="$("$wt/scripts/mobile-worktree-overrides.sh")" ios="$wt/mobile/ios/Flutter/WorktreeOverrides.xcconfig" android="$wt/mobile/android/worktree.properties" [[ -f "$ios" && -f "$android" ]] || fail "worktree must write both override files" -grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile\.feature-work-1$' "$ios" \ +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dev\.mobile\.feature-work-1$' "$ios" \ && pass "iOS bundle identifier keys to the sanitized worktree directory name" \ || fail "iOS bundle identifier must key to the worktree dir, got: $(cat "$ios")" grep -q '^APP_DISPLAY_NAME = Buzz (Fix_Thing-2)$' "$ios" \ @@ -86,7 +86,7 @@ printf '%s' "$out" | grep -q 'Worktree Feature_Work-1' \ # ── Branch switch in the same worktree: identity stable, label follows ─────── git -C "$wt" checkout -q -b "another/branch-name" "$wt/scripts/mobile-worktree-overrides.sh" > /dev/null -grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile\.feature-work-1$' "$ios" \ +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dev\.mobile\.feature-work-1$' "$ios" \ && grep -q '^applicationIdSuffix=\.feature_work_1$' "$android" \ && pass "branch switch keeps the install identity stable (per worktree)" \ || fail "install identity must not change on branch switch" @@ -156,9 +156,9 @@ gradle="$repo_root/mobile/android/app/build.gradle.kts" manifest="$repo_root/mobile/android/app/src/main/AndroidManifest.xml" plist="$repo_root/mobile/ios/Runner/Info.plist" -grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile$' "$debug_xcconfig" \ - && pass "Debug.xcconfig defaults to the dogfood bundle identifier" \ - || fail "Debug.xcconfig must default to xyz.block.buzz.dogfood.mobile" +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dev\.mobile$' "$debug_xcconfig" \ + && pass "Debug.xcconfig defaults to the development bundle identifier" \ + || fail "Debug.xcconfig must default to xyz.block.buzz.dev.mobile" grep -q 'WorktreeOverrides.xcconfig' "$debug_xcconfig" \ && pass "Debug.xcconfig includes WorktreeOverrides" \ || fail "Debug.xcconfig must include WorktreeOverrides.xcconfig" @@ -169,9 +169,9 @@ if [[ -n "$worktree_line" && -n "$app_line" && "$worktree_line" -lt "$app_line" else fail "Debug.xcconfig must include AppOverrides.xcconfig after WorktreeOverrides.xcconfig" fi -grep -q '^ios_prefix="xyz.block.buzz.dogfood.mobile\."$' "$clean_script" \ - && pass "cleanup targets the iOS dogfood worktree prefix" \ - || fail "cleanup must share the iOS dogfood prefix used by worktree overrides" +grep -q '^ios_prefix="xyz.block.buzz.dev.mobile\."$' "$clean_script" \ + && pass "cleanup targets the iOS development worktree prefix" \ + || fail "cleanup must share the iOS development prefix used by worktree overrides" grep -q 'WorktreeOverrides' "$release_xcconfig" \ && fail "Release.xcconfig must not include WorktreeOverrides.xcconfig" \