From 868e2f8f3271af6625981219b0bce2093033e7be Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 9 Oct 2026 15:09:53 -0700 Subject: [PATCH] refactor(push): one app profile per gateway (#8206) Buzz push currently selects an application profile, such as `dogfood`, during client registration. We want each gateway to serve one Apple app configured by the operator instead. This removes profile selection from the client, relay, and gateway, while allowing internal and public gateways to use their own Apple identities. This PR implements that model in the Buzz source and upstream deployment chart. Discovery, enrollment, grants, and leases no longer carry an application-profile selector. Each gateway has one configured Apple application and APNs transport. The protocol changes are incompatible with the old registration format, so relays advertise `buzz-push-v1` instead of `nip-pl`. Old clients do not discover the new push capability. Updated clients use separate Keychain records and register again. The [push notes](https://github.com/block/buzz/blob/push-capability-v1/docs/buzz-push.md) describe the experimental contract. Landing this PR is the first step. Publish the matching gateway image and upstream chart, then land the [shared chart update](https://github.com/squareup/cash-helm-charts/pull/5740) and [gateway deployment update](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/1085). Before cutover, stop old gateway instances and check legacy registration state. Migrations refuse to proceed if old gateway installations or active relay leases remain; they do not delete that state. Physical-device verification remains necessary before production activation. ### Validation Gateway and focused Flutter push tests pass, as do the Swift package, simulator build, and native notification/enrichment tests. A local relay advertises `buzz-push-v1` without `nip-pl` or `app_profiles`. The PostgreSQL suite passed 868 tests; its two migration-test failures were corrected and pass a focused rerun together with the gateway cutover guard. Chart rendering and release-contract checks pass. Desktop JavaScript tests, desktop and web builds, native desktop compilation, mobile analysis/tests, and desktop native tests pass. The full Rust unit lane and prescribed `just test` command pass, including workspace integration tests against task-isolated local services. The production overlay now requires an explicitly supplied APNs topic, with omission and rendered-value regression coverage. The corrected Swift checksum assertion passes the complete package suite: 78 XCTest tests and 42 Swift Testing tests. No physical-device enrollment or production cutover is claimed. Local merge validation: the embedded migration test and formatting pass after reserving profile migration 0060 behind rollback migration 0059. The local pre-push Rust lane failed ACP timing/steering tests (`idle_resets_on_stdout_activity`, `keepalive_resets_idle_past_deadline`, and the failed/missing-outcome steering tests). ACP and its dependency manifests are unchanged by this branch; all four pass in an isolated single-thread rerun. The documented local-check exception skips only the Rust pre-push lane for publication; required CI remains mandatory. Mobile and desktop pre-push lanes passed. Combined migration validation: after rollback 0059 landed, the main merge preserves profile migration 0060 and both embedded migration assertions. The focused migration test and formatting pass. The next local push again failed only the unchanged ACP idle/keepalive timing tests (1053 passed, 2 failed, 3 ignored); other lanes passed. Publication uses the same documented local Rust-lane exception; required CI remains mandatory. Follow-up review fix: removed the legacy profile column from the copied-lease fixture. All four event-follow-up contract tests pass on isolated PostgreSQL databases, covering desired and migrated schemas in dual and app-only modes. Formatting passes. Publication retains the prior passing local-lane evidence and uses the authorized pre-push exception for the known unrelated ACP timing failures; required CI remains mandatory. --------- Signed-off-by: Tom Brow --- VISION_MOBILE.md | 2 +- crates/buzz-db/src/runtime/migration.rs | 58 +++++++++++- crates/buzz-db/src/runtime/tests.rs | 4 +- .../store/event_follow_up_postgres_tests.rs | 5 +- crates/buzz-db/src/store/push.rs | 55 +++++------- .../migrations/0006_single_application.sql | 12 +++ crates/buzz-push-gateway/src/apns.rs | 6 +- crates/buzz-push-gateway/src/authority.rs | 38 ++++---- crates/buzz-push-gateway/src/config.rs | 65 +++++++------- crates/buzz-push-gateway/src/grant.rs | 2 +- crates/buzz-push-gateway/src/http.rs | 89 ++++++------------ .../src/http_delivery_tests.rs | 12 +-- crates/buzz-push-gateway/src/main.rs | 8 +- crates/buzz-push-gateway/src/model.rs | 15 ---- crates/buzz-push-gateway/src/postgres.rs | 47 +++++----- .../src/postgres/bootstrap_tests.rs | 61 +++++++++++++ .../tests/startup_app_attest.rs | 14 +-- .../tests/vectors/app_attest_transcripts.json | 7 +- crates/buzz-relay/src/handlers/push_lease.rs | 61 +++++-------- crates/buzz-relay/src/nip11.rs | 3 +- deploy/charts/buzz-push-gateway/CHANGELOG.md | 4 + deploy/charts/buzz-push-gateway/Chart.yaml | 2 +- .../templates/deployment.yaml | 14 +-- .../charts/buzz-push-gateway/tests/render.sh | 15 +++- .../buzz-push-gateway/values-production.yaml | 6 +- .../buzz-push-gateway/values.schema.json | 73 ++++++--------- deploy/charts/buzz-push-gateway/values.yaml | 17 ++-- docs/buzz-push.md | 21 +++++ docs/push-gateway-deployment.md | 90 ++++++------------- migrations/0060_push_single_application.sql | 28 ++++++ mobile/README.md | 11 ++- .../BuzzDevPushEnrollmentDriver.swift | 45 +++------- .../BuzzPushPendingEnrollmentRecord.swift | 4 - .../BuzzPushKit/BuzzPushTranscript.swift | 10 +-- .../BuzzDevPushEnrollmentDriverTests.swift | 26 ++---- .../BuzzPushTranscriptTests.swift | 2 - mobile/ios/Flutter/Debug.xcconfig | 2 +- .../ios/Runner/PushEndpointGrantStore.swift | 15 +--- mobile/ios/Runner/PushSnapshotBridge.swift | 3 +- .../BuzzCommunicationNotificationTests.swift | 20 ++--- .../age_gate/age_signal_provider.dart | 4 +- .../shared/community/community_provider.dart | 6 +- mobile/lib/shared/push/dev_push_lease.dart | 39 +------- mobile/lib/shared/push/push_bridge.dart | 3 - .../push/push_lease_revocation_outbox.dart | 6 +- mobile/test/features/age_gate/README.md | 6 +- .../age_gate/age_signal_provider_test.dart | 35 ++++---- .../test/shared/push/dev_push_lease_test.dart | 17 ++-- mobile/test/shared/push/push_bridge_test.dart | 2 - .../push_lease_revocation_outbox_test.dart | 1 - .../public/push_gateway_installations.sql | 5 +- schema/tables/public/push_leases.sql | 7 +- scripts/mobile-worktree-clean.sh | 6 +- scripts/mobile-worktree-overrides.sh | 2 +- scripts/test-mobile-worktree-overrides.sh | 16 ++-- 55 files changed, 524 insertions(+), 603 deletions(-) create mode 100644 crates/buzz-push-gateway/migrations/0006_single_application.sql create mode 100644 docs/buzz-push.md create mode 100644 migrations/0060_push_single_application.sql 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" \