diff --git a/.env.example b/.env.example index 0f7bbba6f13..67a33742225 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,12 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# NIP-PL mobile push is an explicit deployment opt-in. A gateway URL alone +# never enables it. When enabled and the URL is absent, the canonical +# https://push.buzz.xyz/v1/deliveries/apns endpoint is used. +BUZZ_PUSH_ENABLED=false +# BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns + # Shared Redis-backed admission limits. Defaults shown below; each value must # be a positive integer. # BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59b9a73ec9b..3e86d10f9ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,7 @@ jobs: - 'pnpm-lock.yaml' mobile: - 'mobile/**' + - 'crates/buzz-push-gateway/tests/vectors/**' - 'scripts/mobile-release.sh' - 'scripts/mobile-worktree-overrides.sh' - 'scripts/mobile-worktree-clean.sh' @@ -71,6 +72,7 @@ jobs: - 'scripts/test-mobile-release-contract.sh' - 'scripts/test-mobile-release-candidate-publisher.sh' - 'scripts/test-mobile-worktree-overrides.sh' + - 'scripts/test-ios-pbxproj-semantics.py' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' - name: Release workflow source contract @@ -900,6 +902,23 @@ jobs: - name: Build Android debug APK run: just mobile-build-android + mobile-swift: + name: Mobile Swift + runs-on: macos-latest + timeout-minutes: 10 + needs: [changes] + if: needs.changes.outputs.mobile == 'true' + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Build + run: swift build --package-path mobile/ios/BuzzPushKit + - name: Build release + run: swift build -c release --package-path mobile/ios/BuzzPushKit + - name: Test + run: swift test --package-path mobile/ios/BuzzPushKit + - name: Validate iOS project semantics + run: python3 scripts/test-ios-pbxproj-semantics.py + security: name: Security runs-on: ubuntu-latest diff --git a/.intersect/sadscan.yaml b/.intersect/sadscan.yaml index a321714bcb5..77710ae0d98 100644 --- a/.intersect/sadscan.yaml +++ b/.intersect/sadscan.yaml @@ -2,3 +2,14 @@ exclude_rules_for_files: sq.pii.cc.visa: - Cargo.lock + # Self-signed test fixture generated solely to exercise reqwest identity parsing. + kingfisher.privkey.2: + - "*apns-test-identity.pem" + - "*apns-test-key-only.pem" + - "*apns-test-encrypted-identity.pem" + - "*apns-test-mismatched-identity.pem" + np.pem.1: + - "*apns-test-identity.pem" + - "*apns-test-key-only.pem" + - "*apns-test-encrypted-identity.pem" + - "*apns-test-mismatched-identity.pem" diff --git a/Cargo.lock b/Cargo.lock index 18c53c18ca0..a4337cef928 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1230,7 +1230,6 @@ dependencies = [ "metrics-exporter-prometheus", "minicbor", "nostr 0.44.7", - "p256", "proptest", "rand 0.10.1", "reqwest 0.13.4", @@ -1910,12 +1909,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -2068,22 +2061,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" -dependencies = [ - "cpubits", - "ctutils", - "getrandom 0.4.3", - "hybrid-array", - "num-traits", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -2101,9 +2078,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.3", "hybrid-array", - "rand_core 0.10.1", ] [[package]] @@ -2171,7 +2146,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", - "subtle", ] [[package]] @@ -2617,21 +2591,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "ecdsa" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" -dependencies = [ - "der", - "digest 0.11.3", - "elliptic-curve", - "rfc6979", - "signature", - "spki", - "zeroize", -] - [[package]] name = "ed25519" version = "3.0.0" @@ -2668,27 +2627,6 @@ dependencies = [ "serde", ] -[[package]] -name = "elliptic-curve" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" -dependencies = [ - "base16ct", - "crypto-bigint", - "crypto-common 0.2.2", - "digest 0.11.3", - "ff", - "group", - "hybrid-array", - "pem-rfc7468", - "pkcs8", - "rand_core 0.10.1", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "embedded-io" version = "0.4.0" @@ -2891,16 +2829,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "ff" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" -dependencies = [ - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3325,17 +3253,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "group" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" -dependencies = [ - "ff", - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "h2" version = "0.4.16" @@ -3678,9 +3595,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ - "subtle", "typenum", - "zeroize", ] [[package]] @@ -6595,19 +6510,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "p256" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primefield", - "primeorder", - "sha2 0.11.0", -] - [[package]] name = "palette" version = "0.7.6" @@ -7092,33 +6994,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "primefield" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" -dependencies = [ - "crypto-bigint", - "crypto-common 0.2.2", - "ff", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - -[[package]] -name = "primeorder" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" -dependencies = [ - "elliptic-curve", - "once_cell", - "primefield", - "serdect", - "wnaf", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -8022,16 +7897,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" -[[package]] -name = "rfc6979" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" -dependencies = [ - "crypto-bigint", - "hmac 0.13.0", -] - [[package]] name = "ring" version = "0.17.14" @@ -8391,20 +8256,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "sec1" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" -dependencies = [ - "base16ct", - "ctutils", - "der", - "hybrid-array", - "subtle", - "zeroize", -] - [[package]] name = "secp256k1" version = "0.29.1" @@ -8828,10 +8679,6 @@ name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "digest 0.11.3", - "rand_core 0.10.1", -] [[package]] name = "simd-adler32" @@ -11508,17 +11355,6 @@ dependencies = [ "windows-core 0.62.2", ] -[[package]] -name = "wnaf" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" -dependencies = [ - "ff", - "group", - "hybrid-array", -] - [[package]] name = "writeable" version = "0.6.3" diff --git a/Justfile b/Justfile index fe5d7bf2858..4f82a558c47 100644 --- a/Justfile +++ b/Justfile @@ -312,9 +312,10 @@ test-unit: cargo nextest run -p buzz-voice --lib cargo nextest run -p buzz-cli # buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra). - # They guard the embedded-migrator invariant (exactly the consolidated - # 0001; cutover/backfill stays an operator script, not startup state) - # and the tenant-scoping lints. The Postgres-backed buzz-db tests are + # They guard the embedded-migrator invariant (the complete checked-in + # additive migration set; legacy cutover/backfill remains an operator + # script, not startup state) and the tenant-scoping lints. The + # Postgres-backed buzz-db tests are # #[ignore]d, so --lib runs only the infra-free set. Without this gate a # stray file in migrations/ or a broken lint ships green. cargo nextest run -p buzz-db --lib diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 94c7aea2faf..2afc4180102 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -1,8 +1,9 @@ //! Embedded SQLx migrations for Buzz. //! -//! Fresh deployments apply the checked-in SQL files under `migrations/`. The -//! multi-tenant rewrite owns a clean consolidated `0001`; legacy single-tenant -//! cutover/backfill is a separate operator script, not startup migration state. +//! Fresh deployments apply the checked-in additive SQL files under +//! `migrations/`. The multi-tenant rewrite begins from a clean consolidated +//! `0001`; legacy single-tenant cutover/backfill is a separate operator script, +//! not startup migration state. use std::future::Future; @@ -640,7 +641,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 32); + assert_eq!(migrations.len(), 33); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1104,6 +1105,22 @@ mod tests { assert!(include_str!("../../../schema/schema.sql").contains("error_code TEXT")); } + #[test] + fn push_match_trigger_is_narrowed_to_message_kinds_additively() { + let mut migrations: Vec<_> = MIGRATOR.iter().collect(); + migrations.sort_by_key(|migration| migration.version); + + assert_eq!(migrations[32].version, 33); + let sql = migrations[32].sql.as_str(); + assert!(sql.contains("CREATE OR REPLACE FUNCTION enqueue_push_match_job")); + assert!(sql.contains("NEW.kind IN (9, 40002, 45001, 45003)")); + assert!(!sql.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)")); + + let desired_schema = include_str!("../../../schema/schema.sql"); + assert!(desired_schema.contains("NEW.kind IN (9, 40002, 45001, 45003)")); + assert!(!desired_schema.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)")); + } + #[test] fn migration_lint_detects_tables_missing_community_id_by_default() { let sql = r#" diff --git a/crates/buzz-db/src/push.rs b/crates/buzz-db/src/push.rs index 0b3245ffcc2..263ef6ee64c 100644 --- a/crates/buzz-db/src/push.rs +++ b/crates/buzz-db/src/push.rs @@ -54,7 +54,7 @@ async fn backfill_push_match_jobs( "INSERT INTO push_match_queue (community_id, event_id) \ SELECT community_id, id FROM events \ WHERE community_id = $1 \ - AND kind IN (7, 9, 1059, 40007, 46010) \ + AND kind IN (9, 40002, 45001, 45003) \ AND deleted_at IS NULL \ AND received_at > now() - make_interval(secs => $2) \ ON CONFLICT DO NOTHING", @@ -157,6 +157,8 @@ pub struct ClaimedWake { pub class: String, /// Delivery deadline, in Unix seconds. pub expires_at: i64, + /// Time this durable wake entered the relay outbox. + pub queued_at: DateTime, /// Attempt number, starting at one for the first claim. pub attempt: i32, } @@ -225,11 +227,17 @@ pub async fn accept_lease_event( address_lock.extend_from_slice(community.as_uuid().as_bytes()); address_lock.extend_from_slice(author); address_lock.extend_from_slice(installation_id.as_bytes()); - let address_lock = i64::from_le_bytes(Sha256::digest(&address_lock)[..8].try_into().unwrap()); + let address_digest = Sha256::digest(&address_lock); + let mut address_lock_bytes = [0_u8; 8]; + address_lock_bytes.copy_from_slice(&address_digest[..8]); + let address_lock = i64::from_le_bytes(address_lock_bytes); let mut author_lock = Vec::with_capacity(16 + author.len()); author_lock.extend_from_slice(community.as_uuid().as_bytes()); author_lock.extend_from_slice(author); - let author_lock = i64::from_le_bytes(Sha256::digest(&author_lock)[..8].try_into().unwrap()); + let author_digest = Sha256::digest(&author_lock); + let mut author_lock_bytes = [0_u8; 8]; + author_lock_bytes.copy_from_slice(&author_digest[..8]); + let author_lock = i64::from_le_bytes(author_lock_bytes); sqlx::query("SELECT pg_advisory_xact_lock($1)") .bind(address_lock) .execute(&mut *tx) @@ -597,10 +605,10 @@ pub async fn enqueue_wake( }], ) .await?; - Ok(outcomes + outcomes .into_iter() .next() - .expect("one outcome per request")) + .ok_or_else(|| crate::DbError::InvalidData("missing wake enqueue outcome".into())) } /// Set-wise counterpart of [`enqueue_wake`]: one transaction and a constant @@ -1066,7 +1074,8 @@ pub async fn claim_due_wakes( AND l.endpoint_hash = o.endpoint_hash RETURNING o.community_id, o.id, o.claim_id, o.event_id, c.channel_id, o.author, o.installation_id, o.lease_generation, - l.endpoint_grant, o.class, o.expires_at, o.attempts + l.endpoint_grant, o.class, o.expires_at, o.created_at AS queued_at, + o.attempts "#, ) .bind(community.as_uuid()) @@ -1094,7 +1103,8 @@ pub async fn revalidate_wake_for_send( r#" SELECT o.community_id, o.id, o.claim_id, o.event_id, e.channel_id, o.author, o.installation_id, o.lease_generation, - l.endpoint_grant, o.class, o.expires_at, o.attempts + l.endpoint_grant, o.class, o.expires_at, o.created_at AS queued_at, + o.attempts FROM push_wake_outbox o JOIN push_leases l ON l.community_id = o.community_id @@ -1257,6 +1267,7 @@ fn row_to_claimed_wake(row: sqlx::postgres::PgRow) -> Result { endpoint_grant: row.try_get("endpoint_grant")?, class: row.try_get("class")?, expires_at: row.try_get("expires_at")?, + queued_at: row.try_get("queued_at")?, attempt: row.try_get("attempts")?, }) } diff --git a/crates/buzz-push-gateway/Cargo.toml b/crates/buzz-push-gateway/Cargo.toml index aec3c43b026..c5a5368f3c8 100644 --- a/crates/buzz-push-gateway/Cargo.toml +++ b/crates/buzz-push-gateway/Cargo.toml @@ -7,6 +7,10 @@ rust-version.workspace = true license.workspace = true repository.workspace = true +[features] +default = [] +dev-app-attest-bypass = [] + [lib] name = "buzz_push_gateway" path = "src/lib.rs" @@ -29,9 +33,8 @@ getrandom = "0.4" metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } nostr = { workspace = true } -p256 = { version = "0.14", features = ["ecdsa", "pem", "pkcs8"] } rand = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["http2"] } serde = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } diff --git a/crates/buzz-push-gateway/migrations/0002_application_profiles.sql b/crates/buzz-push-gateway/migrations/0002_application_profiles.sql new file mode 100644 index 00000000000..45be402dc07 --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0002_application_profiles.sql @@ -0,0 +1,18 @@ +-- The original profile names encoded APNs transport environment, not a +-- verified application identity. They therefore cannot be mapped safely to +-- either closed bundle profile. Retire the pre-profile demo authority and let +-- clients re-attest under the exact server-owned application profile. +DELETE FROM push_gateway_delegations +WHERE installation_id IN ( + SELECT id FROM push_gateway_installations + WHERE app_profile IN ('buzz-ios-production', 'buzz-ios-sandbox') +); + +DELETE FROM push_gateway_installations +WHERE app_profile IN ('buzz-ios-production', 'buzz-ios-sandbox'); + +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_profile_check; +ALTER TABLE push_gateway_installations + ADD CONSTRAINT push_gateway_installations_app_profile_check + CHECK (app_profile IN ('buzz-ios-dogfood', 'buzz-ios-app-store')); diff --git a/crates/buzz-push-gateway/src/apns.rs b/crates/buzz-push-gateway/src/apns.rs index 8f6f1820001..99b3532cf01 100644 --- a/crates/buzz-push-gateway/src/apns.rs +++ b/crates/buzz-push-gateway/src/apns.rs @@ -1,21 +1,13 @@ //! APNs envelope construction, endpoint encryption, and response classification. -use std::{sync::Mutex, time::Duration}; +use std::time::Duration; use async_trait::async_trait; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use p256::{ - ecdsa::{signature::Signer, Signature, SigningKey}, - pkcs8::DecodePrivateKey, -}; -use reqwest::{ - header::{AUTHORIZATION, CONTENT_TYPE}, - StatusCode, -}; +use reqwest::{header::CONTENT_TYPE, StatusCode}; use serde::Deserialize; use thiserror::Error; -use crate::model::{AppProfile, APNS_RECONNECT_PAYLOAD}; +use crate::{config::ApnsEnvironment, model::APNS_RECONNECT_PAYLOAD}; /// Sanitized delivery outcome. Raw provider bodies never cross this boundary. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -32,8 +24,6 @@ pub enum DeliveryOutcome { /// Retry-After delay in seconds, clamped by the transport. retry_after_seconds: Option, }, - /// Refresh the cached provider JWT, then retry once within normal attempt bounds. - RefreshCredential, /// Provider credential/profile configuration is unhealthy; do not invalidate endpoints. ConfigurationFault, /// The locally-generated request is permanently invalid. @@ -52,7 +42,6 @@ pub fn classify(code: u16, reason: Option<&str>, timestamp: Option) -> Deli unregistered_at: None, } } - (403, Some("ExpiredProviderToken")) => DeliveryOutcome::RefreshCredential, (403, _) | (429, Some("TooManyProviderTokenUpdates")) => { DeliveryOutcome::ConfigurationFault } @@ -85,110 +74,84 @@ pub struct DeliveryAttempt { #[async_trait] pub trait PushTransport: Send + Sync { /// Send one durable job. - async fn send( - &self, - attempt: DeliveryAttempt, - profile: AppProfile, - endpoint: &str, - ) -> DeliveryOutcome; - /// Discard a cached credential after APNs reports expiry. - fn refresh_credential(&self) {} + async fn send(&self, attempt: DeliveryAttempt, endpoint: &str) -> DeliveryOutcome; } -struct CachedJwt { - token: String, - issued_at: i64, -} - -/// Direct HTTP/2 APNs transport using a cached ES256 provider token. +/// Direct HTTP/2 APNs transport using a client certificate identity. pub struct ApnsTransport { client: reqwest::Client, - signing_key: SigningKey, - key_id: String, - team_id: String, topic: String, - production_base_url: String, - sandbox_base_url: String, - cached_jwt: Mutex>, + base_url: String, } impl ApnsTransport { - /// Build a reusable APNs client from an Apple `.p8` private key. - pub fn token(p8: &[u8], key_id: &str, team_id: &str, topic: String) -> Result { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .map_err(|_| ApnsError::Client)?; - Self::token_with_client( - p8, - key_id, - team_id, - topic, - client, - "https://api.push.apple.com".to_owned(), - "https://api.sandbox.push.apple.com".to_owned(), - ) + /// Build a reusable APNs client from a combined PEM private key and certificate. + pub fn certificate( + identity_pem: &[u8], + topic: String, + environment: ApnsEnvironment, + ) -> Result { + let base_url = match environment { + ApnsEnvironment::Production => "https://api.push.apple.com", + ApnsEnvironment::Sandbox => "https://api.sandbox.push.apple.com", + }; + Self::certificate_with_base_url(identity_pem, topic, base_url.to_owned()) } - fn token_with_client( - p8: &[u8], - key_id: &str, - team_id: &str, + fn certificate_with_base_url( + identity_pem: &[u8], topic: String, - client: reqwest::Client, - production_base_url: String, - sandbox_base_url: String, + base_url: String, ) -> Result { - let pem = std::str::from_utf8(p8).map_err(|_| ApnsError::Credential)?; - let signing_key = SigningKey::from_pkcs8_pem(pem).map_err(|_| ApnsError::Credential)?; + let identity = + reqwest::Identity::from_pem(identity_pem).map_err(|_| ApnsError::Credential)?; + let client = reqwest::Client::builder() + // APNs requires HTTP/2. This no-op method reference is intentionally + // feature-gated so removing reqwest's `http2` feature fails the build. + .http2_keep_alive_while_idle(false) + .identity(identity) + .timeout(Duration::from_secs(15)) + // Identity validation completes while the TLS client is built, so a + // malformed or mismatched certificate/key pair is a credential error. + .build() + .map_err(|_| ApnsError::Credential)?; Ok(Self { client, - signing_key, - key_id: key_id.to_owned(), - team_id: team_id.to_owned(), topic, - production_base_url, - sandbox_base_url, - cached_jwt: Mutex::new(None), + base_url, }) } - fn jwt(&self, now: i64) -> Result { - let mut cached = self.cached_jwt.lock().map_err(|_| ApnsError::Credential)?; - if let Some(jwt) = cached.as_ref().filter(|jwt| now - jwt.issued_at < 50 * 60) { - return Ok(jwt.token.clone()); - } - let header = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&serde_json::json!({"alg":"ES256","kid":self.key_id})) - .map_err(|_| ApnsError::Credential)?, - ); - let claims = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&serde_json::json!({"iss":self.team_id,"iat":now})) - .map_err(|_| ApnsError::Credential)?, - ); - let signing_input = format!("{header}.{claims}"); - let signature: Signature = self.signing_key.sign(signing_input.as_bytes()); - let token = format!( - "{signing_input}.{}", - URL_SAFE_NO_PAD.encode(signature.to_bytes()) - ); - *cached = Some(CachedJwt { - token: token.clone(), - issued_at: now, - }); - Ok(token) + fn request(&self, attempt: DeliveryAttempt, endpoint: &str) -> reqwest::RequestBuilder { + self.client + .post(format!("{}/3/device/{endpoint}", self.base_url)) + .header(CONTENT_TYPE, "application/json") + .header("apns-id", attempt.request_id.to_string()) + .header("apns-topic", &self.topic) + .header("apns-push-type", "alert") + .header("apns-priority", "10") + .header("apns-expiration", attempt.expires_at.to_string()) + // This is the only APNs application body in the program. It is a + // byte constant, not a serialization of the relay request, grant, + // endpoint, headers, route, provider response, or any generic JSON map. + .body(APNS_RECONNECT_PAYLOAD) + } + + async fn send_response( + &self, + attempt: DeliveryAttempt, + endpoint: &str, + ) -> Result { + self.request(attempt, endpoint).send().await } } /// APNs transport setup failure. It intentionally carries no credential material. #[derive(Debug, Error)] pub enum ApnsError { - /// Invalid provider key material. + /// Invalid client certificate identity material. #[error("invalid APNs credential")] Credential, - /// HTTP client setup failed. - #[error("failed to construct APNs client")] - Client, } #[derive(Deserialize)] @@ -199,38 +162,9 @@ struct ApnsErrorBody { #[async_trait] impl PushTransport for ApnsTransport { - async fn send( - &self, - attempt: DeliveryAttempt, - profile: AppProfile, - endpoint: &str, - ) -> DeliveryOutcome { - // This is the only APNs application body in the program. It is a - // byte constant, not a serialization of the relay request, grant, - // endpoint, headers, route, provider response, or any generic JSON map. - let body = APNS_RECONNECT_PAYLOAD; - let now = chrono::Utc::now().timestamp(); - let token = match self.jwt(now) { - Ok(token) => token, - Err(_) => return DeliveryOutcome::ConfigurationFault, - }; - let base_url = match profile { - AppProfile::BuzzIosProduction => &self.production_base_url, - AppProfile::BuzzIosSandbox => &self.sandbox_base_url, - }; - let response = self - .client - .post(format!("{base_url}/3/device/{endpoint}")) - .header(AUTHORIZATION, format!("bearer {token}")) - .header(CONTENT_TYPE, "application/json") - .header("apns-id", attempt.request_id.to_string()) - .header("apns-topic", &self.topic) - .header("apns-push-type", "alert") - .header("apns-priority", "10") - .header("apns-expiration", attempt.expires_at.to_string()) - .body(body) - .send() - .await; + async fn send(&self, attempt: DeliveryAttempt, endpoint: &str) -> DeliveryOutcome { + crate::metrics::record_apns_send_attempt(); + let response = self.send_response(attempt, endpoint).await; let response = match response { Ok(response) => response, Err(_) => { @@ -262,63 +196,66 @@ impl PushTransport for ApnsTransport { outcome => outcome, } } - - fn refresh_credential(&self) { - if let Ok(mut cached) = self.cached_jwt.lock() { - *cached = None; - } - } } #[cfg(test)] mod tests { use super::*; - use axum::{body::Bytes, extract::State, http::StatusCode, routing::post, Router}; - use p256::pkcs8::{EncodePrivateKey, LineEnding}; - use std::sync::Arc; + use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + routing::post, + Router, + }; + use std::sync::{Arc, Mutex}; + + // Self-signed test-only identity material. None of these are Apple credentials. + const TEST_IDENTITY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-identity.pem"); + const TEST_CERT_ONLY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-cert-only.pem"); + const TEST_KEY_ONLY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-key-only.pem"); + const TEST_ENCRYPTED_IDENTITY_PEM: &[u8] = + include_bytes!("../tests/fixtures/apns-test-encrypted-identity.pem"); + const TEST_MISMATCHED_IDENTITY_PEM: &[u8] = + include_bytes!("../tests/fixtures/apns-test-mismatched-identity.pem"); - async fn capture_body( - State(bodies): State>>>>, + #[derive(Default)] + struct CapturedRequest { + headers: HeaderMap, + body: Vec, + } + + async fn capture_request( + State(requests): State>>>, + headers: HeaderMap, body: Bytes, ) -> StatusCode { - bodies.lock().unwrap().push(body.to_vec()); + requests.lock().unwrap().push(CapturedRequest { + headers, + body: body.to_vec(), + }); StatusCode::OK } + #[tokio::test] - async fn real_outbound_http_body_is_the_exact_constant_for_every_attempt() { - let bodies = Arc::new(Mutex::new(Vec::new())); + async fn certificate_transport_sends_no_bearer_and_exact_body_for_every_attempt() { + let requests = Arc::new(Mutex::new(Vec::new())); let app = Router::new() - .route("/3/device/{endpoint}", post(capture_body)) - .with_state(bodies.clone()); + .route("/3/device/{endpoint}", post(capture_request)) + .with_state(requests.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let signing_key = SigningKey::from_slice(&[7; 32]).unwrap(); - let pem = signing_key.to_pkcs8_pem(LineEnding::LF).unwrap(); - let transport = ApnsTransport::token_with_client( - pem.as_bytes(), - "kid", - "team", + let transport = ApnsTransport::certificate_with_base_url( + TEST_IDENTITY_PEM, "app.topic".to_owned(), - reqwest::Client::new(), - base_url.clone(), base_url, ) .unwrap(); - for (request_id, expires_at, profile, endpoint) in [ - ( - uuid::Uuid::nil(), - 1, - AppProfile::BuzzIosProduction, - "00".repeat(32), - ), - ( - uuid::Uuid::max(), - i64::MAX, - AppProfile::BuzzIosSandbox, - "ff".repeat(32), - ), + for (request_id, expires_at, endpoint) in [ + (uuid::Uuid::nil(), 1, "00".repeat(32)), + (uuid::Uuid::max(), i64::MAX, "ff".repeat(32)), ] { assert_eq!( transport @@ -327,18 +264,94 @@ mod tests { request_id, expires_at, }, - profile, &endpoint, ) .await, DeliveryOutcome::Accepted ); } - let captured = bodies.lock().unwrap(); + let captured = requests.lock().unwrap(); assert_eq!(captured.len(), 2); assert!(captured .iter() - .all(|body| body.as_slice() == APNS_RECONNECT_PAYLOAD)); + .all(|request| request.body.as_slice() == APNS_RECONNECT_PAYLOAD)); + assert!(captured + .iter() + .all(|request| !request.headers.contains_key(reqwest::header::AUTHORIZATION))); + assert!(captured.iter().all(|request| request + .headers + .get("apns-topic") + .is_some_and(|topic| topic == "app.topic"))); + } + + #[tokio::test] + #[ignore = "requires the exported dogfood 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"); + let topic = std::env::var("BUZZ_PUSH_LIVE_APNS_TOPIC") + .expect("set BUZZ_PUSH_LIVE_APNS_TOPIC to the dogfood bundle id"); + let identity = std::fs::read(cert_path).unwrap(); + let transport = + ApnsTransport::certificate(&identity, topic, ApnsEnvironment::Sandbox).unwrap(); + let response = transport + .send_response( + DeliveryAttempt { + request_id: uuid::Uuid::nil(), + expires_at: chrono::Utc::now().timestamp() + 60, + }, + &"00".repeat(32), + ) + .await + .unwrap(); + let status = response.status(); + let body = response.text().await.unwrap(); + eprintln!("live APNs response: status={status}, body={body}"); + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); + assert_eq!(body, r#"{"reason":"BadDeviceToken"}"#); + } + + #[test] + fn empty_certificate_identity_fails_as_a_credential_error() { + assert_credential_error(b""); + } + + #[test] + fn malformed_certificate_identity_fails_as_a_credential_error() { + assert_credential_error(b"not a PEM identity"); + } + + #[test] + fn certificate_without_private_key_fails_as_a_credential_error() { + assert_credential_error(TEST_CERT_ONLY_PEM); + } + + #[test] + fn private_key_without_certificate_fails_as_a_credential_error() { + assert_credential_error(TEST_KEY_ONLY_PEM); + } + + #[test] + fn encrypted_private_key_fails_as_a_credential_error() { + assert_credential_error(TEST_ENCRYPTED_IDENTITY_PEM); + } + + #[test] + fn mismatched_private_key_fails_as_a_credential_error() { + // reqwest parses both PEM blocks, then rejects the mismatched pair while + // building the TLS client. This locks the ClientBuilder error mapping. + assert_credential_error(TEST_MISMATCHED_IDENTITY_PEM); + } + + fn assert_credential_error(identity_pem: &[u8]) { + assert!(matches!( + ApnsTransport::certificate( + identity_pem, + "app.topic".to_owned(), + ApnsEnvironment::Production, + ), + Err(ApnsError::Credential) + )); } #[test] @@ -349,10 +362,12 @@ mod tests { unregistered_at: Some(7) } ); - assert_eq!( - classify(403, Some("InvalidProviderToken"), None), - DeliveryOutcome::ConfigurationFault - ); + for reason in ["InvalidProviderToken", "ExpiredProviderToken"] { + assert_eq!( + classify(403, Some(reason), None), + DeliveryOutcome::ConfigurationFault + ); + } assert_eq!( classify(429, Some("TooManyRequests"), None), DeliveryOutcome::Retry { diff --git a/crates/buzz-push-gateway/src/app_attest.rs b/crates/buzz-push-gateway/src/app_attest.rs index ebb1fc56bc0..302e2dd5add 100644 --- a/crates/buzz-push-gateway/src/app_attest.rs +++ b/crates/buzz-push-gateway/src/app_attest.rs @@ -45,6 +45,14 @@ impl AppAttestVerifier { apple_root_cert_pem, }) } + #[cfg(all(test, feature = "dev-app-attest-bypass"))] + pub(crate) fn for_policy_test() -> Self { + Self { + app_id: "policy-test".to_owned(), + apple_root_cert_pem: Vec::new(), + } + } + /// `client_data` is the exact canonical enrollment transcript represented by /// the challenge string passed to `attestKey`; callers must include every /// authority-bearing enrollment field in it. @@ -138,3 +146,330 @@ fn assertion_counter(cbor: &[u8]) -> Result { .ok_or(AppAttestError::Invalid)?; Ok(BigEndian::read_u32(&auth[33..37])) } + +#[cfg(test)] +mod tests { + use super::*; + use appattest::error::AppAttestError as DependencyAppAttestError; + use chrono::{Duration, NaiveDateTime, Utc}; + use serde::Deserialize; + + const GOOD_FIXTURE_JSON: &str = include_str!("../tests/fixtures/app-attest-good.json"); + const WRONG_AAGUID_FIXTURE_JSON: &str = + include_str!("../tests/fixtures/app-attest-wrong-aaguid.json"); + const WRONG_ROOT_FIXTURE_JSON: &str = + include_str!("../tests/fixtures/app-attest-wrong-root.json"); + const APPLE_ROOT_CERT_PEM: &[u8] = + include_bytes!("../tests/fixtures/apple-app-attestation-root.pem"); + + #[derive(Deserialize)] + struct Fixture { + description: String, + generator: String, + generated_at: String, + regeneration_command: String, + app_id: String, + challenge: String, + aaguid: String, + leaf_not_after: String, + attestation_b64: String, + key_id_b64: String, + root_cert_pem: String, + } + + fn fixture(json: &str) -> Fixture { + let fixture: Fixture = serde_json::from_str(json).expect("valid App Attest fixture JSON"); + assert!(!fixture.description.is_empty()); + assert_eq!( + fixture.generator, + "crates/buzz-push-gateway/tests/fixtures/app-attest-generator" + ); + assert!(!fixture.generated_at.is_empty()); + fixture + } + + fn verifier(app_id: &str, root_cert_pem: &[u8]) -> AppAttestVerifier { + AppAttestVerifier { + app_id: app_id.to_owned(), + apple_root_cert_pem: root_cert_pem.to_vec(), + } + } + + fn verify_dependency( + fixture: &Fixture, + app_id: &str, + challenge: &str, + key_id_b64: &str, + root_cert_pem: &[u8], + ) -> Result<(), DependencyAppAttestError> { + let cbor = STANDARD + .decode(&fixture.attestation_b64) + .expect("fixture attestation is base64"); + let attestation = Attestation::from_cbor_bytes(&cbor)?; + let result = attestation + .verify(challenge, app_id, key_id_b64, root_cert_pem) + .map(|_| ()); + result + } + + #[test] + fn strict_verifier_accepts_good_fixture() { + let fixture = fixture(GOOD_FIXTURE_JSON); + assert_eq!(fixture.aaguid, "appattest"); + verify_dependency( + &fixture, + &fixture.app_id, + &fixture.challenge, + &fixture.key_id_b64, + fixture.root_cert_pem.as_bytes(), + ) + .expect("strict dependency verifier accepts the generated encoding"); + + let verified = verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &fixture.key_id_b64, + fixture.challenge.as_bytes(), + ) + .expect("shipped gateway wrapper accepts the generated encoding"); + assert_eq!(verified.key_id.len(), 32); + assert_eq!(verified.public_key.len(), 65); + } + + #[test] + fn wrong_root_is_rejected() { + let good = fixture(GOOD_FIXTURE_JSON); + let wrong_root = fixture(WRONG_ROOT_FIXTURE_JSON); + assert!(verify_dependency( + &wrong_root, + &wrong_root.app_id, + &wrong_root.challenge, + &wrong_root.key_id_b64, + good.root_cert_pem.as_bytes(), + ) + .is_err()); + assert!(verifier(&wrong_root.app_id, good.root_cert_pem.as_bytes()) + .verify_attestation( + &wrong_root.attestation_b64, + &wrong_root.key_id_b64, + wrong_root.challenge.as_bytes(), + ) + .is_err()); + } + + #[test] + fn wrong_app_id_is_rejected() { + let fixture = fixture(GOOD_FIXTURE_JSON); + let wrong_app_id = "TEAMID.xyz.buzz.wrong"; + assert_eq!( + verify_dependency( + &fixture, + wrong_app_id, + &fixture.challenge, + &fixture.key_id_b64, + fixture.root_cert_pem.as_bytes(), + ), + Err(DependencyAppAttestError::InvalidAppID) + ); + assert!(verifier(wrong_app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &fixture.key_id_b64, + fixture.challenge.as_bytes(), + ) + .is_err()); + } + + #[test] + fn wrong_challenge_is_rejected() { + let fixture = fixture(GOOD_FIXTURE_JSON); + let wrong_challenge = "wrong-challenge"; + assert_eq!( + verify_dependency( + &fixture, + &fixture.app_id, + wrong_challenge, + &fixture.key_id_b64, + fixture.root_cert_pem.as_bytes(), + ), + Err(DependencyAppAttestError::InvalidNonce) + ); + assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &fixture.key_id_b64, + wrong_challenge.as_bytes(), + ) + .is_err()); + } + + #[test] + fn wrong_aaguid_is_rejected_as_invalid_aaguid() { + let fixture = fixture(WRONG_AAGUID_FIXTURE_JSON); + assert_eq!(fixture.aaguid, "appattestdevelop"); + assert_eq!( + verify_dependency( + &fixture, + &fixture.app_id, + &fixture.challenge, + &fixture.key_id_b64, + fixture.root_cert_pem.as_bytes(), + ), + Err(DependencyAppAttestError::InvalidAAGUID) + ); + assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &fixture.key_id_b64, + fixture.challenge.as_bytes(), + ) + .is_err()); + } + + #[test] + fn short_and_oversize_key_ids_are_rejected() { + let fixture = fixture(GOOD_FIXTURE_JSON); + for key_id_b64 in [STANDARD.encode([0x11; 31]), STANDARD.encode([0x22; 33])] { + assert!(verify_dependency( + &fixture, + &fixture.app_id, + &fixture.challenge, + &key_id_b64, + fixture.root_cert_pem.as_bytes(), + ) + .is_err()); + assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &key_id_b64, + fixture.challenge.as_bytes(), + ) + .is_err()); + } + } + + #[test] + #[allow(clippy::assertions_on_constants, unexpected_cfgs)] + fn gateway_test_build_does_not_define_testing_feature() { + assert!(!cfg!(feature = "testing")); + } + + #[test] + fn constructor_still_pins_the_apple_root() { + let fixture = fixture(GOOD_FIXTURE_JSON); + assert!( + AppAttestVerifier::new(fixture.app_id.clone(), APPLE_ROOT_CERT_PEM.to_vec()).is_ok() + ); + assert!( + AppAttestVerifier::new(fixture.app_id, fixture.root_cert_pem.as_bytes().to_vec(),) + .is_err() + ); + } + + #[test] + fn fixture_leaf_certificate_is_valid_for_at_least_thirty_days() { + let fixture = fixture(GOOD_FIXTURE_JSON); + let leaf_certificate = fixture_leaf_certificate(&fixture); + let not_after = certificate_not_after(&leaf_certificate); + assert_eq!( + not_after.format("%b %e %H:%M:%S %Y GMT").to_string(), + fixture.leaf_not_after + ); + assert!( + not_after > Utc::now() + Duration::days(30), + "App Attest fixture expires within 30 days; regenerate with: {}", + fixture.regeneration_command + ); + } + + fn fixture_leaf_certificate(fixture: &Fixture) -> Vec { + let cbor = STANDARD + .decode(&fixture.attestation_b64) + .expect("fixture attestation is base64"); + let mut decoder = minicbor::Decoder::new(&cbor); + let root_entries = decoder + .map() + .expect("attestation root is a map") + .expect("attestation root map has a fixed length"); + for _ in 0..root_entries { + let key = decoder.str().expect("attestation root key is text"); + if key != "attStmt" { + decoder.skip().expect("skip non-attStmt value"); + continue; + } + let statement_entries = decoder + .map() + .expect("attStmt is a map") + .expect("attStmt map has a fixed length"); + for _ in 0..statement_entries { + let key = decoder.str().expect("attStmt key is text"); + if key != "x5c" { + decoder.skip().expect("skip non-x5c value"); + continue; + } + assert!( + decoder + .array() + .expect("x5c is an array") + .expect("x5c has a fixed length") + >= 2 + ); + return decoder + .bytes() + .expect("x5c leaf certificate is bytes") + .to_vec(); + } + } + panic!("fixture attestation has no x5c leaf certificate"); + } + + fn certificate_not_after(certificate: &[u8]) -> chrono::DateTime { + let (tag, certificate, _) = der_tlv(certificate); + assert_eq!(tag, 0x30, "certificate must be a DER sequence"); + let (tag, tbs_certificate, _) = der_tlv(certificate); + assert_eq!(tag, 0x30, "TBSCertificate must be a DER sequence"); + + let mut fields = tbs_certificate; + if fields.first() == Some(&0xa0) { + fields = der_tlv(fields).2; + } + for _ in 0..3 { + fields = der_tlv(fields).2; + } + let (tag, validity, _) = der_tlv(fields); + assert_eq!(tag, 0x30, "certificate validity must be a DER sequence"); + let (_, _, after_not_before) = der_tlv(validity); + let (time_tag, not_after, _) = der_tlv(after_not_before); + let not_after = std::str::from_utf8(not_after).expect("notAfter is ASCII"); + let format = match time_tag { + 0x17 => "%y%m%d%H%M%SZ", + 0x18 => "%Y%m%d%H%M%SZ", + _ => panic!("unexpected DER time tag {time_tag:#x}"), + }; + NaiveDateTime::parse_from_str(not_after, format) + .expect("valid DER notAfter timestamp") + .and_utc() + } + + fn der_tlv(input: &[u8]) -> (u8, &[u8], &[u8]) { + let tag = *input.first().expect("DER TLV has a tag"); + let first_length = *input.get(1).expect("DER TLV has a length"); + let (length, length_bytes) = if first_length & 0x80 == 0 { + (first_length as usize, 1) + } else { + let byte_count = (first_length & 0x7f) as usize; + assert!( + byte_count > 0 && byte_count <= std::mem::size_of::(), + "supported DER long-form length" + ); + let length = input[2..2 + byte_count] + .iter() + .fold(0_usize, |length, byte| (length << 8) | *byte as usize); + (length, 1 + byte_count) + }; + let value_start = 1 + length_bytes; + let value_end = value_start + length; + assert!(value_end <= input.len(), "DER TLV length is in bounds"); + (tag, &input[value_start..value_end], &input[value_end..]) + } +} diff --git a/crates/buzz-push-gateway/src/app_attest_policy.rs b/crates/buzz-push-gateway/src/app_attest_policy.rs new file mode 100644 index 00000000000..77c724d9e60 --- /dev/null +++ b/crates/buzz-push-gateway/src/app_attest_policy.rs @@ -0,0 +1,123 @@ +//! Selects the production Apple verifier or the feature-gated development stub. + +use crate::app_attest::{ + AppAttestError, AppAttestVerifier, VerifiedAssertion, VerifiedAttestation, +}; +#[cfg(feature = "dev-app-attest-bypass")] +#[derive(Clone)] +pub struct DevelopmentAppAttestPolicy { + _private: (), +} + +#[cfg(feature = "dev-app-attest-bypass")] +pub struct DevelopmentAppAttestBypass { + _private: (), +} + +#[cfg(feature = "dev-app-attest-bypass")] +impl DevelopmentAppAttestBypass { + pub(crate) fn enabled() -> Self { + Self { _private: () } + } +} + +#[cfg_attr( + not(feature = "dev-app-attest-bypass"), + doc = r#" +The development policy is structurally unavailable in default builds: + +```compile_fail +use buzz_push_gateway::app_attest_policy::AppAttestPolicy; + +let _ = AppAttestPolicy::Development; +``` +"# +)] +#[derive(Clone)] +pub enum AppAttestPolicy { + Apple(AppAttestVerifier), + #[cfg(feature = "dev-app-attest-bypass")] + Development(DevelopmentAppAttestPolicy), +} + +impl AppAttestPolicy { + pub fn apple(verifier: AppAttestVerifier) -> Self { + Self::Apple(verifier) + } + + #[cfg(feature = "dev-app-attest-bypass")] + pub fn from_config( + bypass: Option, + apple: AppAttestVerifier, + ) -> Self { + if bypass.is_some() { + tracing::warn!( + "DEVELOPMENT APP ATTEST BYPASS ACTIVE; Apple attestation and assertion verification are disabled" + ); + Self::development() + } else { + Self::apple(apple) + } + } + + #[cfg(feature = "dev-app-attest-bypass")] + fn development() -> Self { + Self::Development(DevelopmentAppAttestPolicy { _private: () }) + } + + pub fn verify_attestation( + &self, + attestation_b64: &str, + key_id_b64: &str, + client_data: &[u8], + ) -> Result { + match self { + Self::Apple(verifier) => { + verifier.verify_attestation(attestation_b64, key_id_b64, client_data) + } + #[cfg(feature = "dev-app-attest-bypass")] + Self::Development(_) => { + crate::dev_app_attest::verify_attestation(attestation_b64, key_id_b64, client_data) + } + } + } + + pub fn verify_assertion( + &self, + assertion_b64: &str, + client_data: &[u8], + public_key: &[u8], + previous_counter: u32, + challenge: &str, + stored_challenge: &str, + ) -> Result { + match self { + Self::Apple(verifier) => verifier.verify_assertion( + assertion_b64, + client_data, + public_key, + previous_counter, + challenge, + stored_challenge, + ), + #[cfg(feature = "dev-app-attest-bypass")] + Self::Development(_) => crate::dev_app_attest::verify_assertion( + assertion_b64, + client_data, + public_key, + previous_counter, + challenge, + stored_challenge, + ), + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn development_bypass_stays_a_non_default_feature() { + assert!(include_str!("../Cargo.toml") + .contains("[features]\ndefault = []\ndev-app-attest-bypass = []")); + } +} diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 36c220885cd..ae70064afb9 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -519,7 +519,7 @@ mod tests { app_attest_key_id: vec![1], app_attest_public_key: vec![2; 33], assertion_counter: 0, - profile: AppProfile::BuzzIosProduction, + profile: AppProfile::BuzzIosDogfood, token_ciphertext: vec![3], 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 c6194edbcb4..8e4d5512436 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "dev-app-attest-bypass")] +use crate::app_attest_policy::DevelopmentAppAttestBypass; use base64::{engine::general_purpose::STANDARD, Engine as _}; use std::{ collections::{HashMap, HashSet}, @@ -6,6 +8,21 @@ use std::{ }; use thiserror::Error; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApnsEnvironment { + Production, + Sandbox, +} + +#[derive(Debug, Clone)] +pub struct AppProfileConfig { + pub enabled: bool, + pub app_attest_app_id: String, + pub apns_cert_path: Option, + pub apns_topic: String, + pub apns_environment: ApnsEnvironment, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct KeyConfig { pub id: String, @@ -21,19 +38,19 @@ pub struct Config { pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, pub endpoint_quota_max_deliveries: i64, - pub enabled_profiles: HashSet, + /// Closed server-owned registry. Both known application identities are + /// present even when one is dormant; only enabled entries carry a required + /// APNs identity and can enroll or deliver. + pub profiles: HashMap, pub database_url: String, - pub app_attest_app_id: String, pub app_attest_root_cert_path: PathBuf, + #[cfg(feature = "dev-app-attest-bypass")] + pub dev_app_attest_bypass: bool, /// Ordered current key first, followed by decrypt-only predecessors. pub grant_keys: Vec, /// Independent token-custody keyring. These keys MUST NOT be reused for /// externally presented delivery capabilities. pub token_keys: Vec, - pub apns_key_path: PathBuf, - pub apns_key_id: String, - pub apns_team_id: String, - pub apns_topic: String, } #[derive(Debug, Error)] pub enum ConfigError { @@ -75,7 +92,66 @@ fn parse_keyring( } Ok(keys) } + +fn parse_profile( + e: &HashMap, + prefix: &'static str, + enabled: bool, +) -> Result { + let app_id_key = match prefix { + "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", + "APP_STORE" => "BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID", + _ => unreachable!("closed profile prefix"), + }; + let cert_key = match prefix { + "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", + "APP_STORE" => "BUZZ_PUSH_APP_STORE_APNS_CERT_PATH", + _ => unreachable!("closed profile prefix"), + }; + let topic_key = match prefix { + "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APNS_TOPIC", + "APP_STORE" => "BUZZ_PUSH_APP_STORE_APNS_TOPIC", + _ => unreachable!("closed profile prefix"), + }; + let environment_key = match prefix { + "DOGFOOD" => "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", + "APP_STORE" => "BUZZ_PUSH_APP_STORE_APNS_ENVIRONMENT", + _ => unreachable!("closed profile prefix"), + }; + let required = |key: &'static str| { + e.get(key) + .map(String::as_str) + .filter(|value| !value.is_empty()) + .ok_or(ConfigError::Missing(key)) + }; + let app_attest_app_id = required(app_id_key)?.to_owned(); + let apns_topic = required(topic_key)?.to_owned(); + let apns_cert_path = match e.get(cert_key).filter(|value| !value.is_empty()) { + Some(path) => Some(PathBuf::from(path)), + None if enabled => return Err(ConfigError::Missing(cert_key)), + None => None, + }; + let apns_environment = match e.get(environment_key).map(String::as_str) { + None | Some("production") => ApnsEnvironment::Production, + Some("sandbox") => ApnsEnvironment::Sandbox, + Some(_) => return Err(ConfigError::Invalid(environment_key)), + }; + Ok(AppProfileConfig { + enabled, + app_attest_app_id, + apns_cert_path, + apns_topic, + apns_environment, + }) +} + impl Config { + #[cfg(feature = "dev-app-attest-bypass")] + pub fn dev_app_attest_bypass(&self) -> Option { + self.dev_app_attest_bypass + .then(DevelopmentAppAttestBypass::enabled) + } + pub fn from_env() -> Result { Self::from_map(&std::env::vars().collect()) } @@ -144,42 +220,86 @@ impl Config { let enabled_profiles = req(e, "BUZZ_PUSH_ENABLED_PROFILES")? .split(',') .map(|profile| match profile { - "buzz-ios-production" => Ok(crate::model::AppProfile::BuzzIosProduction), - "buzz-ios-sandbox" => Ok(crate::model::AppProfile::BuzzIosSandbox), + "buzz-ios-dogfood" => Ok(crate::model::AppProfile::BuzzIosDogfood), + "buzz-ios-app-store" => Ok(crate::model::AppProfile::BuzzIosAppStore), _ => Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")), }) .collect::, _>>()?; if enabled_profiles.is_empty() { return Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")); } + let profiles = HashMap::from([ + ( + crate::model::AppProfile::BuzzIosDogfood, + parse_profile( + e, + "DOGFOOD", + enabled_profiles.contains(&crate::model::AppProfile::BuzzIosDogfood), + )?, + ), + ( + crate::model::AppProfile::BuzzIosAppStore, + parse_profile( + e, + "APP_STORE", + enabled_profiles.contains(&crate::model::AppProfile::BuzzIosAppStore), + )?, + ), + ]); + let bind_addr = e + .get("BUZZ_PUSH_BIND_ADDR") + .map(String::as_str) + .unwrap_or("0.0.0.0:8080") + .parse::() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_BIND_ADDR"))?; + let health_addr = e + .get("BUZZ_PUSH_HEALTH_ADDR") + .map(String::as_str) + .unwrap_or("0.0.0.0:8081") + .parse::() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?; + let dev_app_attest_bypass_requested = + match e.get("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS").map(String::as_str) { + None | Some("0") => false, + Some("1") => true, + Some(_) => return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")), + }; + #[cfg(not(feature = "dev-app-attest-bypass"))] + if dev_app_attest_bypass_requested { + return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); + } + #[cfg(feature = "dev-app-attest-bypass")] + let dev_app_attest_bypass = dev_app_attest_bypass_requested; + #[cfg(feature = "dev-app-attest-bypass")] + if dev_app_attest_bypass + && (!bind_addr.ip().is_loopback() || !health_addr.ip().is_loopback()) + { + return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); + } + #[cfg(feature = "dev-app-attest-bypass")] + if dev_app_attest_bypass + && (enabled_profiles.len() != 1 + || !enabled_profiles.contains(&crate::model::AppProfile::BuzzIosDogfood) + || profiles[&crate::model::AppProfile::BuzzIosDogfood].apns_environment + != ApnsEnvironment::Sandbox) + { + return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")); + } Ok(Self { - bind_addr: e - .get("BUZZ_PUSH_BIND_ADDR") - .map(String::as_str) - .unwrap_or("0.0.0.0:8080") - .parse() - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_BIND_ADDR"))?, - health_addr: e - .get("BUZZ_PUSH_HEALTH_ADDR") - .map(String::as_str) - .unwrap_or("0.0.0.0:8081") - .parse() - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?, + bind_addr, + health_addr, public_delivery_url, max_grant_lifetime_seconds, max_installation_lifetime_seconds, endpoint_quota_window_seconds, endpoint_quota_max_deliveries, - enabled_profiles, + profiles, database_url: req(e, "DATABASE_URL")?.to_owned(), - app_attest_app_id: req(e, "BUZZ_PUSH_APP_ATTEST_APP_ID")?.to_owned(), app_attest_root_cert_path: req(e, "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH")?.into(), + #[cfg(feature = "dev-app-attest-bypass")] + dev_app_attest_bypass, grant_keys, token_keys, - apns_key_path: req(e, "BUZZ_PUSH_APNS_KEY_PATH")?.into(), - apns_key_id: req(e, "BUZZ_PUSH_APNS_KEY_ID")?.to_owned(), - apns_team_id: req(e, "BUZZ_PUSH_APNS_TEAM_ID")?.to_owned(), - apns_topic: req(e, "BUZZ_PUSH_APNS_TOPIC")?.to_owned(), }) } } @@ -187,6 +307,10 @@ impl Config { #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "dev-app-attest-bypass")] + use crate::app_attest_policy::AppAttestPolicy; + #[cfg(feature = "dev-app-attest-bypass")] + use sha2::Digest as _; fn base() -> HashMap { HashMap::from([ @@ -216,24 +340,75 @@ mod tests { ), ( "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-production".into(), + "buzz-ios-dogfood".into(), ), ( "DATABASE_URL".into(), - "postgres://buzz:test@localhost/buzz".into(), + "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_STORE_APP_ATTEST_APP_ID".into(), + "TEAM.xyz.block.buzz.mobile".into(), ), - ("BUZZ_PUSH_APP_ATTEST_APP_ID".into(), "TEAM.app".into()), ( "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH".into(), "/apple-root.pem".into(), ), - ("BUZZ_PUSH_APNS_KEY_PATH".into(), "/key.p8".into()), - ("BUZZ_PUSH_APNS_KEY_ID".into(), "key".into()), - ("BUZZ_PUSH_APNS_TEAM_ID".into(), "team".into()), - ("BUZZ_PUSH_APNS_TOPIC".into(), "app".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_APP_STORE_APNS_TOPIC".into(), + "xyz.block.buzz.mobile".into(), + ), + ("BUZZ_PUSH_BIND_ADDR".into(), "127.0.0.1:8080".into()), + ("BUZZ_PUSH_HEALTH_ADDR".into(), "127.0.0.1:8081".into()), ]) } + #[test] + fn enabled_profile_requires_its_certificate_and_all_profiles_have_server_owned_identity() { + let config = Config::from_map(&base()).unwrap(); + let dogfood = &config.profiles[&crate::model::AppProfile::BuzzIosDogfood]; + assert!(dogfood.enabled); + assert_eq!( + dogfood.apns_cert_path, + Some(PathBuf::from("/dogfood-identity.pem")) + ); + assert_eq!(dogfood.apns_topic, "xyz.block.buzz.dogfood.mobile"); + let app_store = &config.profiles[&crate::model::AppProfile::BuzzIosAppStore]; + assert!(!app_store.enabled); + assert_eq!(app_store.apns_cert_path, None); + assert_eq!(app_store.apns_topic, "xyz.block.buzz.mobile"); + + for variable in [ + "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", + "BUZZ_PUSH_DOGFOOD_APNS_TOPIC", + "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", + "BUZZ_PUSH_APP_STORE_APNS_TOPIC", + "BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID", + ] { + let mut env = base(); + env.remove(variable); + assert!( + matches!(Config::from_map(&env), Err(ConfigError::Missing(key)) if key == variable) + ); + } + } + #[test] fn keyrings_preserve_current_then_predecessor_order_and_are_independent() { let config = Config::from_map(&base()).unwrap(); @@ -255,7 +430,8 @@ mod tests { "BUZZ_PUSH_PUBLIC_DELIVERY_URL", "https://push.example/v1/deliveries/apns", ), - ("BUZZ_PUSH_APP_ATTEST_APP_ID", ""), + ("BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", ""), + ("BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", "staging"), ("BUZZ_PUSH_ENABLED_PROFILES", "unknown-profile"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "31536001"), @@ -279,6 +455,218 @@ mod tests { } } + #[test] + fn listener_defaults_remain_public_when_addresses_are_absent() { + let mut env = base(); + env.remove("BUZZ_PUSH_BIND_ADDR"); + env.remove("BUZZ_PUSH_HEALTH_ADDR"); + + let config = Config::from_map(&env).unwrap(); + assert_eq!(config.bind_addr, "0.0.0.0:8080".parse().unwrap()); + assert_eq!(config.health_addr, "0.0.0.0:8081".parse().unwrap()); + } + + #[test] + fn dogfood_and_app_store_profiles_parse_together_without_bypass() { + let mut env = base(); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood,buzz-ios-app-store".into(), + ); + env.insert( + "BUZZ_PUSH_APP_STORE_APNS_CERT_PATH".into(), + "/app-store-identity.pem".into(), + ); + + let config = Config::from_map(&env).unwrap(); + assert_eq!(config.profiles.len(), 2); + assert!(config.profiles[&crate::model::AppProfile::BuzzIosDogfood].enabled); + assert!(config.profiles[&crate::model::AppProfile::BuzzIosAppStore].enabled); + } + + #[test] + fn dev_app_attest_bypass_flag_is_strict_and_feature_gated() { + let absent = Config::from_map(&base()).unwrap(); + #[cfg(feature = "dev-app-attest-bypass")] + assert!(!absent.dev_app_attest_bypass); + #[cfg(not(feature = "dev-app-attest-bypass"))] + let _ = absent; + + let mut disabled = base(); + disabled.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "0".into()); + let disabled = Config::from_map(&disabled).unwrap(); + #[cfg(feature = "dev-app-attest-bypass")] + assert!(!disabled.dev_app_attest_bypass); + #[cfg(not(feature = "dev-app-attest-bypass"))] + let _ = disabled; + + for value in ["", "false", "true", "TRUE", "yes", " 1"] { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), value.into()); + assert!( + matches!( + Config::from_map(&env), + Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")) + ), + "accepted non-canonical value {value:?}" + ); + } + + #[cfg(not(feature = "dev-app-attest-bypass"))] + { + let mut enabled = base(); + enabled.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + assert!(matches!( + Config::from_map(&enabled), + Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")) + )); + } + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_requires_both_loopback_listeners() { + for (key, value) in [ + ("BUZZ_PUSH_BIND_ADDR", "0.0.0.0:8080"), + ("BUZZ_PUSH_HEALTH_ADDR", "[::]:8081"), + ] { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood".into(), + ); + env.insert( + "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), + "sandbox".into(), + ); + env.insert(key.into(), value.into()); + assert!(Config::from_map(&env).is_err(), "accepted {key}={value}"); + } + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn non_loopback_bind_is_rejected_before_loopback_equivalent_is_accepted() { + let mut non_loopback = base(); + non_loopback.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + non_loopback.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood".into(), + ); + non_loopback.insert( + "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), + "sandbox".into(), + ); + non_loopback.insert("BUZZ_PUSH_BIND_ADDR".into(), "0.0.0.0:8080".into()); + assert!(Config::from_map(&non_loopback).is_err()); + + non_loopback.insert("BUZZ_PUSH_BIND_ADDR".into(), "127.0.0.1:8080".into()); + assert!( + Config::from_map(&non_loopback) + .unwrap() + .dev_app_attest_bypass + ); + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_requires_sandbox_dogfood_as_the_only_profile() { + for profiles in ["buzz-ios-app-store", "buzz-ios-dogfood,buzz-ios-app-store"] { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + env.insert("BUZZ_PUSH_ENABLED_PROFILES".into(), profiles.into()); + assert!( + Config::from_map(&env).is_err(), + "accepted profiles {profiles}" + ); + } + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_accepts_explicit_one_for_loopback_sandbox() { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood".into(), + ); + env.insert( + "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), + "sandbox".into(), + ); + assert!(Config::from_map(&env).unwrap().dev_app_attest_bypass); + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn second_profile_is_rejected_before_sandbox_only_is_accepted() { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood,buzz-ios-app-store".into(), + ); + env.insert( + "BUZZ_PUSH_APP_STORE_APNS_CERT_PATH".into(), + "/app-store-identity.pem".into(), + ); + assert!(Config::from_map(&env).is_err()); + + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood".into(), + ); + env.insert( + "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), + "sandbox".into(), + ); + assert!(Config::from_map(&env).unwrap().dev_app_attest_bypass); + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn dev_app_attest_bypass_selects_development_policy_from_validated_config() { + let mut env = base(); + env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "1".into()); + env.insert( + "BUZZ_PUSH_ENABLED_PROFILES".into(), + "buzz-ios-dogfood".into(), + ); + env.insert( + "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), + "sandbox".into(), + ); + let config = Config::from_map(&env).unwrap(); + let apple = crate::app_attest::AppAttestVerifier::for_policy_test(); + + let policy = AppAttestPolicy::from_config(config.dev_app_attest_bypass(), apple); + + assert!(matches!(policy, AppAttestPolicy::Development(_))); + } + + #[cfg(feature = "dev-app-attest-bypass")] + #[test] + fn bypass_unset_keeps_sentinel_on_the_apple_verifier() { + let config = Config::from_map(&base()).unwrap(); + let policy = AppAttestPolicy::from_config( + config.dev_app_attest_bypass(), + crate::app_attest::AppAttestVerifier::for_policy_test(), + ); + let mut sentinel = b"buzz-dev-app-attest-v1:".to_vec(); + sentinel.extend_from_slice(&[1; 32]); + let key_id = sha2::Sha256::digest(&sentinel); + + assert!(policy + .verify_attestation( + &STANDARD.encode(sentinel), + &STANDARD.encode(key_id), + b"canonical enrollment transcript", + ) + .is_err()); + } + #[test] fn malformed_or_empty_keyrings_fail_startup() { for (variable, value) in [ diff --git a/crates/buzz-push-gateway/src/dev_app_attest.rs b/crates/buzz-push-gateway/src/dev_app_attest.rs new file mode 100644 index 00000000000..038d8b524d7 --- /dev/null +++ b/crates/buzz-push-gateway/src/dev_app_attest.rs @@ -0,0 +1,168 @@ +//! Explicit development-only App Attest sentinel verification. +//! +//! This module is absent unless the non-default `dev-app-attest-bypass` Cargo +//! feature is enabled. Runtime configuration adds a second, independent gate. + +use crate::app_attest::{AppAttestError, VerifiedAssertion, VerifiedAttestation}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use sha2::{Digest, Sha256}; + +const ATTESTATION_PREFIX: &[u8] = b"buzz-dev-app-attest-v1:"; +const ASSERTION_SENTINEL: &[u8] = b"buzz-dev-app-assertion-v1"; +const PUBLIC_KEY_PREFIX: &[u8] = b"buzz-dev-app-attest-public-key-v1:"; +const NONCE_BYTES: usize = 32; + +pub fn assertion_sentinel() -> String { + STANDARD.encode(ASSERTION_SENTINEL) +} + +pub fn verify_attestation( + attestation_b64: &str, + key_id_b64: &str, + client_data: &[u8], +) -> Result { + let attestation = STANDARD + .decode(attestation_b64) + .map_err(|_| AppAttestError::Invalid)?; + let supplied_key_id = STANDARD + .decode(key_id_b64) + .map_err(|_| AppAttestError::Invalid)?; + let expected_key_id = Sha256::digest(&attestation); + if !attestation.starts_with(ATTESTATION_PREFIX) + || attestation.len() != ATTESTATION_PREFIX.len() + NONCE_BYTES + || supplied_key_id.as_slice() != expected_key_id.as_slice() + || client_data.is_empty() + { + return Err(AppAttestError::Invalid); + } + let mut public_key = PUBLIC_KEY_PREFIX.to_vec(); + public_key.extend_from_slice(&expected_key_id); + Ok(VerifiedAttestation { + key_id: expected_key_id.to_vec(), + public_key, + }) +} + +pub fn verify_assertion( + assertion_b64: &str, + client_data: &[u8], + public_key: &[u8], + previous_counter: u32, + challenge: &str, + stored_challenge: &str, +) -> Result { + let assertion = STANDARD + .decode(assertion_b64) + .map_err(|_| AppAttestError::Invalid)?; + if assertion != ASSERTION_SENTINEL + || client_data.is_empty() + || !public_key.starts_with(PUBLIC_KEY_PREFIX) + || public_key.len() != PUBLIC_KEY_PREFIX.len() + 32 + || challenge.is_empty() + || challenge != stored_challenge + { + return Err(AppAttestError::Invalid); + } + Ok(VerifiedAssertion { + counter: previous_counter + .checked_add(1) + .ok_or(AppAttestError::Invalid)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn attestation(nonce: u8) -> (String, String) { + let mut sentinel = ATTESTATION_PREFIX.to_vec(); + sentinel.extend_from_slice(&[nonce; NONCE_BYTES]); + let key_id = Sha256::digest(&sentinel); + (STANDARD.encode(sentinel), STANDARD.encode(key_id)) + } + + #[test] + fn versioned_attestation_derives_a_unique_bound_key_id() { + let (attestation_a, key_id_a) = attestation(1); + let verified_a = verify_attestation( + &attestation_a, + &key_id_a, + b"canonical enrollment transcript", + ) + .unwrap(); + assert_eq!(verified_a.key_id.len(), 32); + assert!(verified_a.public_key.starts_with(PUBLIC_KEY_PREFIX)); + + let (attestation_b, key_id_b) = attestation(2); + let verified_b = verify_attestation( + &attestation_b, + &key_id_b, + b"canonical enrollment transcript", + ) + .unwrap(); + assert_ne!(verified_a.key_id, verified_b.key_id); + + for (attestation, key, transcript) in [ + ("bad".to_owned(), key_id_a.clone(), b"transcript".as_slice()), + (attestation_a.clone(), key_id_b, b"transcript"), + (attestation_a, key_id_a, b""), + ] { + assert!(verify_attestation(&attestation, &key, transcript).is_err()); + } + } + + #[test] + fn bad_attestation_sentinel_is_rejected_before_good_sentinel_is_accepted() { + let (good_attestation, key_id) = attestation(1); + let bad_attestation = STANDARD.encode(b"buzz-dev-app-attest-v1:bad"); + + assert!(verify_attestation(&bad_attestation, &key_id, b"transcript").is_err()); + assert!(verify_attestation(&good_attestation, &key_id, b"transcript").is_ok()); + } + + #[test] + fn exact_assertion_sentinel_and_stored_dev_marker_advance_counter() { + let (attestation, key_id) = attestation(1); + let public_key = verify_attestation(&attestation, &key_id, b"transcript") + .unwrap() + .public_key; + let verified = verify_assertion( + &assertion_sentinel(), + b"canonical assertion transcript", + &public_key, + 7, + "challenge", + "challenge", + ) + .unwrap(); + assert_eq!(verified.counter, 8); + + assert!(verify_assertion( + "bad", + b"canonical assertion transcript", + &public_key, + 7, + "challenge", + "challenge", + ) + .is_err()); + assert!(verify_assertion( + &assertion_sentinel(), + b"canonical assertion transcript", + b"not-the-development-marker", + 7, + "challenge", + "challenge", + ) + .is_err()); + assert!(verify_assertion( + &assertion_sentinel(), + b"canonical assertion transcript", + &public_key, + u32::MAX, + "challenge", + "challenge", + ) + .is_err()); + } +} diff --git a/crates/buzz-push-gateway/src/grant.rs b/crates/buzz-push-gateway/src/grant.rs index 54a29bac3d1..8eda1d7ce81 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::BuzzIosProduction, + 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 0564972c078..4033825d369 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -1,7 +1,7 @@ //! Stateful installation, delegation, delivery, and health APIs. use crate::{ apns::{DeliveryAttempt, DeliveryOutcome, PushTransport}, - app_attest::AppAttestVerifier, + app_attest_policy::AppAttestPolicy, authority::{ AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation, }, @@ -23,7 +23,7 @@ use nostr::{ Event, JsonUtil, Timestamp, }; use std::{ - collections::HashSet, + collections::HashMap, sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -33,19 +33,33 @@ use std::{ use tower::limit::ConcurrencyLimitLayer; use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer}; +#[derive(Clone)] +pub struct ProfileRuntime { + /// Both values are absent for a registered but dormant profile. + pub app_attest: Option>, + pub transport: Option>, +} + +impl ProfileRuntime { + fn enabled(&self) -> bool { + self.app_attest.is_some() && self.transport.is_some() + } +} + #[derive(Clone)] pub struct AppState { pub grant_keyring: Arc, - pub app_attest: Arc, pub authority: Arc, pub token_keyring: Arc, - pub transport: Arc, + /// Closed server-owned app identity registry. Client profile selectors + /// choose only a candidate; App Attest verifies the configured application + /// ID before the profile is persisted as installation authority. + pub profiles: Arc>, pub delivery_url: url::Url, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, pub endpoint_quota_max_deliveries: i64, - pub enabled_profiles: HashSet, pub now: fn() -> i64, pub accepting: Arc, } @@ -163,11 +177,14 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Some(v) => v, None => return error(StatusCode::BAD_REQUEST, "invalid_request"), }; + let profile = match s.profiles.get(&r.app_profile) { + Some(profile) if profile.enabled() => profile, + _ => return error(StatusCode::BAD_REQUEST, "invalid_request"), + }; if r.v != WIRE_VERSION || r.endpoint_epoch != 1 || r.expires_at <= now || r.expires_at > now.saturating_add(s.max_installation_lifetime_seconds) - || !s.enabled_profiles.contains(&r.app_profile) { return error(StatusCode::BAD_REQUEST, "invalid_request"); } @@ -190,14 +207,14 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Some(v) => v, None => return error(StatusCode::BAD_REQUEST, "invalid_request"), }; - let verified = - match s - .app_attest + let verified = match profile.app_attest.as_ref().and_then(|policy| { + policy .verify_attestation(&r.attestation, &r.key_id, signed.as_bytes()) - { - Ok(v) => v, - Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), - }; + .ok() + }) { + Some(value) => value, + None => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), + }; if let Err(e) = s .authority .consume_challenge(r.challenge_id, challenge, now) @@ -252,10 +269,14 @@ async fn verify_installation_assertion( .installation(installation_id, now) .await .map_err(authority_error)?; + let app_attest = s + .profiles + .get(&installation.profile) + .and_then(|profile| profile.app_attest.as_ref()) + .ok_or_else(|| error(StatusCode::NOT_FOUND, "not_authorized"))?; let transcript = transcript(domain, signed) .ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?; - let verified = s - .app_attest + let verified = app_attest .verify_assertion( assertion, transcript.as_bytes(), @@ -635,6 +656,22 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> return error(StatusCode::NOT_FOUND, "invalid_grant"); } let profile = permit.authority.profile; + let transport = match s + .profiles + .get(&profile) + .and_then(|runtime| runtime.transport.as_ref()) + .cloned() + { + Some(transport) => transport, + None => { + crate::metrics::record_delivery_error("profile_disabled"); + let _ = s + .authority + .finish_delivery(permit, DeliveryDisposition::Retryable) + .await; + return error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault"); + } + }; let endpoint = match s.token_keyring.open(&permit.authority.token_ciphertext) { Ok(token) => hex::encode(token), Err(_) => { @@ -650,23 +687,17 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> request_id: r.request_id, expires_at: r.expires_at, }; - let transport = Arc::clone(&s.transport); let authority_store = Arc::clone(&s.authority); // Admission already committed, so cancellation cannot undo either replay // fence. The detached task completes disposition bookkeeping. let delivery = tokio::spawn(async move { let started = std::time::Instant::now(); - let mut outcome = transport.send(attempt, profile, &endpoint).await; - if outcome == DeliveryOutcome::RefreshCredential { - crate::metrics::record_credential_refresh(); - transport.refresh_credential(); - outcome = transport.send(attempt, profile, &endpoint).await; - } + let outcome = transport.send(attempt, &endpoint).await; crate::metrics::record_apns_delivery(outcome, started.elapsed().as_secs_f64()); let disposition = match outcome { - DeliveryOutcome::Retry { .. } - | DeliveryOutcome::ConfigurationFault - | DeliveryOutcome::RefreshCredential => DeliveryDisposition::Retryable, + DeliveryOutcome::Retry { .. } | DeliveryOutcome::ConfigurationFault => { + DeliveryDisposition::Retryable + } DeliveryOutcome::Accepted | DeliveryOutcome::InvalidEndpoint { .. } | DeliveryOutcome::PermanentRequestFault => DeliveryDisposition::Terminal, @@ -704,7 +735,7 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> }), ) .into_response(), - DeliveryOutcome::ConfigurationFault | DeliveryOutcome::RefreshCredential => { + DeliveryOutcome::ConfigurationFault => { error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault") } DeliveryOutcome::PermanentRequestFault => error(StatusCode::BAD_REQUEST, "invalid_request"), @@ -774,3 +805,146 @@ pub fn router_with_metrics( } (public, health) } + +/// Known-answer vectors for the exact App Attest transcript bytes defined by +/// NIP-PL ("Exact App Attest transcript construction"). The fixture file is +/// shared ground truth with client-side canonical encoders (the Swift NIP-PL +/// iOS client): a client encoder that fails to reproduce these bytes exactly +/// fails every enroll/delegate/rotate/revoke call with `invalid_attestation`. +#[cfg(test)] +mod transcript_vector_tests { + use super::*; + use sha2::{Digest, Sha256}; + + const VECTORS_JSON: &str = include_str!("../tests/vectors/app_attest_transcripts.json"); + + // Deterministic fixture inputs mirrored in the vector file's `inputs`. + const CHALLENGE_ID: uuid::Uuid = + uuid::Uuid::from_u128(0x1111_1111_1111_4111_8111_1111_1111_1111); + const INSTALLATION: uuid::Uuid = + uuid::Uuid::from_u128(0x2222_2222_2222_4222_8222_2222_2222_2222); + // base64url-no-pad of bytes 0x00..=0x1f. + const CHALLENGE: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; + // Standard base64 (padded) of 32 bytes of 0xAA. + const KEY_ID: &str = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo="; + // 32-byte APNs token, lowercase hex. + const ENDPOINT: &str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + const RELAY_PUBKEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn assert_vector(name: &str, actual: &str) { + let file: serde_json::Value = serde_json::from_str(VECTORS_JSON).unwrap(); + let vector = file["vectors"] + .as_array() + .unwrap() + .iter() + .find(|v| v["name"] == name) + .unwrap_or_else(|| panic!("vector {name} missing from fixture")); + assert_eq!( + actual, + vector["transcript"].as_str().unwrap(), + "{name} bytes" + ); + assert_eq!( + hex::encode(Sha256::digest(actual.as_bytes())), + vector["sha256"].as_str().unwrap(), + "{name} sha256" + ); + } + + #[test] + fn fixture_encodings_match_their_raw_bytes() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let challenge_bytes: Vec = (0u8..32).collect(); + assert_eq!(URL_SAFE_NO_PAD.encode(&challenge_bytes), CHALLENGE); + assert_eq!(STANDARD.encode([0xAAu8; 32]), KEY_ID); + assert_eq!(hex::decode(ENDPOINT).unwrap().len(), 32); + } + + #[test] + fn enroll_transcript_vector() { + let t = EnrollTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/installations", + 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, + }; + assert_vector("enroll", &transcript("buzz.push.enroll.v1", &t).unwrap()); + } + + #[test] + fn delegate_transcript_vector() { + let t = DelegateTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/delegations", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + endpoint_epoch: 1, + generation: 1, + relay_pubkey: RELAY_PUBKEY, + not_before: 1_752_620_000, + expires_at: 1_752_624_000, + }; + assert_vector( + "delegate", + &transcript("buzz.push.delegate.v1", &t).unwrap(), + ); + } + + #[test] + fn rotate_endpoint_transcript_vector() { + let t = RotateTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/installations/endpoint", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + endpoint_epoch: 1, + new_endpoint_epoch: 2, + endpoint: ENDPOINT, + }; + assert_vector( + "rotate_endpoint", + &transcript("buzz.push.rotate-endpoint.v1", &t).unwrap(), + ); + } + + #[test] + fn revoke_delegation_transcript_vector() { + let t = RevokeDelegationTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/delegations/revoke", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + relay_pubkey: RELAY_PUBKEY, + generation: 2, + }; + assert_vector( + "revoke_delegation", + &transcript("buzz.push.revoke-delegation.v1", &t).unwrap(), + ); + } + + #[test] + fn revoke_installation_transcript_vector() { + let t = RevokeInstallationTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/installations/revoke", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + endpoint_epoch: 1, + new_endpoint_epoch: 2, + }; + assert_vector( + "revoke_installation", + &transcript("buzz.push.revoke-installation.v1", &t).unwrap(), + ); + } +} diff --git a/crates/buzz-push-gateway/src/lib.rs b/crates/buzz-push-gateway/src/lib.rs index 563d725db99..59fa2ca09bd 100644 --- a/crates/buzz-push-gateway/src/lib.rs +++ b/crates/buzz-push-gateway/src/lib.rs @@ -1,8 +1,11 @@ //! Stateful, capability-gated APNs last hop for NIP-PL. pub mod apns; pub mod app_attest; +pub mod app_attest_policy; pub mod authority; pub mod config; +#[cfg(feature = "dev-app-attest-bypass")] +pub mod dev_app_attest; pub mod grant; pub mod http; pub mod metrics; diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index 55e1853d3bf..a2613dfb374 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -1,6 +1,7 @@ use buzz_push_gateway::{ apns::ApnsTransport, app_attest::AppAttestVerifier, + app_attest_policy::AppAttestPolicy, authority::AuthorityStore, config::Config, grant::{GrantKey, GrantKeyring}, @@ -10,6 +11,7 @@ use buzz_push_gateway::{ AppState, }; use std::{ + collections::HashMap, fs, sync::{ atomic::{AtomicBool, Ordering}, @@ -35,12 +37,41 @@ async fn main() -> Result<(), Box> { } let c = Config::from_env()?; let metrics_handle = buzz_push_gateway::metrics::install()?; - let transport = Arc::new(ApnsTransport::token( - &fs::read(&c.apns_key_path)?, - &c.apns_key_id, - &c.apns_team_id, - c.apns_topic, - )?); + let app_attest_root = fs::read(&c.app_attest_root_cert_path)?; + let mut profiles = HashMap::new(); + for (profile, configured) in &c.profiles { + let runtime = if configured.enabled { + let cert_path = configured.apns_cert_path.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "enabled push profile has no APNs identity path", + ) + })?; + let transport = Arc::new(ApnsTransport::certificate( + &fs::read(cert_path)?, + configured.apns_topic.clone(), + configured.apns_environment, + )?); + let apple = AppAttestVerifier::new( + configured.app_attest_app_id.clone(), + app_attest_root.clone(), + )?; + #[cfg(feature = "dev-app-attest-bypass")] + let policy = AppAttestPolicy::from_config(c.dev_app_attest_bypass(), apple); + #[cfg(not(feature = "dev-app-attest-bypass"))] + let policy = AppAttestPolicy::apple(apple); + buzz_push_gateway::http::ProfileRuntime { + app_attest: Some(Arc::new(policy)), + transport: Some(transport), + } + } else { + buzz_push_gateway::http::ProfileRuntime { + app_attest: None, + transport: None, + } + }; + profiles.insert(*profile, runtime); + } let grant_keyring = GrantKeyring::new( c.grant_keys .iter() @@ -77,24 +108,18 @@ async fn main() -> Result<(), Box> { } } }); - let app_attest = Arc::new(AppAttestVerifier::new( - c.app_attest_app_id, - fs::read(&c.app_attest_root_cert_path)?, - )?); let accepting = Arc::new(AtomicBool::new(true)); let (public, health) = router_with_metrics( AppState { grant_keyring: Arc::new(grant_keyring), - app_attest, authority, token_keyring: Arc::new(token_keyring), - transport, + profiles: Arc::new(profiles), delivery_url: c.public_delivery_url, 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, endpoint_quota_max_deliveries: c.endpoint_quota_max_deliveries, - enabled_profiles: c.enabled_profiles, now: || chrono::Utc::now().timestamp(), accepting: accepting.clone(), }, diff --git a/crates/buzz-push-gateway/src/metrics.rs b/crates/buzz-push-gateway/src/metrics.rs index f40c126c79a..dfc45f467c0 100644 --- a/crates/buzz-push-gateway/src/metrics.rs +++ b/crates/buzz-push-gateway/src/metrics.rs @@ -41,18 +41,24 @@ pub fn install() -> Result { /// Stable metric label for each sanitized delivery outcome. The mapping is total /// over the closed [`DeliveryOutcome`] enum, so the `outcome` label can only take -/// these six values. +/// these five values. fn outcome_label(outcome: DeliveryOutcome) -> &'static str { match outcome { DeliveryOutcome::Accepted => "accepted", DeliveryOutcome::InvalidEndpoint { .. } => "invalid_endpoint", DeliveryOutcome::Retry { .. } => "retry", - DeliveryOutcome::RefreshCredential => "refresh_credential", DeliveryOutcome::ConfigurationFault => "configuration_fault", DeliveryOutcome::PermanentRequestFault => "permanent_request_fault", } } +/// Record entry into the concrete APNs HTTP send seam. This counter is kept +/// separate from terminal outcomes so a control scrape can distinguish +/// "transport never reached" from "APNs send returned an error". +pub fn record_apns_send_attempt() { + metrics::counter!("push_gateway_apns_send_attempts_total").increment(1); +} + /// Record the terminal APNs outcome and its send round-trip latency. pub fn record_apns_delivery(outcome: DeliveryOutcome, seconds: f64) { metrics::counter!("push_gateway_apns_deliveries_total", "outcome" => outcome_label(outcome)) @@ -60,11 +66,6 @@ pub fn record_apns_delivery(outcome: DeliveryOutcome, seconds: f64) { metrics::histogram!("push_gateway_apns_delivery_seconds").record(seconds); } -/// Record that a cached provider credential was refreshed after APNs reported expiry. -pub fn record_credential_refresh() { - metrics::counter!("push_gateway_apns_credential_refreshes_total").increment(1); -} - /// Delivery-admission result at the `authorize_delivery` seam. #[derive(Debug, Clone, Copy)] pub enum Admission { @@ -126,7 +127,7 @@ mod tests { #[test] fn outcome_label_covers_every_variant_with_static_strings() { // Exhaustive over the closed enum; each arm is a compile-time constant, - // so the `outcome` label is structurally bounded to these six values. + // so the `outcome` label is structurally bounded to these five values. for (outcome, expected) in [ (DeliveryOutcome::Accepted, "accepted"), ( @@ -141,7 +142,6 @@ mod tests { }, "retry", ), - (DeliveryOutcome::RefreshCredential, "refresh_credential"), (DeliveryOutcome::ConfigurationFault, "configuration_fault"), ( DeliveryOutcome::PermanentRequestFault, @@ -159,6 +159,7 @@ mod tests { fn recorder_renders_sanitized_bounded_series() { let handle = install().expect("recorder installs exactly once per test process"); + record_apns_send_attempt(); record_apns_delivery(DeliveryOutcome::Accepted, 0.012); record_apns_delivery( DeliveryOutcome::InvalidEndpoint { @@ -166,7 +167,6 @@ mod tests { }, 0.030, ); - record_credential_refresh(); record_admission(Admission::Admitted); record_admission(Admission::Rejected); record_admission(Admission::Unavailable); @@ -180,9 +180,9 @@ mod tests { // All expected series are present. for needle in [ + "push_gateway_apns_send_attempts_total", "push_gateway_apns_deliveries_total", "push_gateway_apns_delivery_seconds", - "push_gateway_apns_credential_refreshes_total", "push_gateway_admissions_total", "push_gateway_delivery_errors_total", "push_gateway_reaper_failures_total", diff --git a/crates/buzz-push-gateway/src/model.rs b/crates/buzz-push-gateway/src/model.rs index 23f8015fe00..574d1d6b9c2 100644 --- a/crates/buzz-push-gateway/src/model.rs +++ b/crates/buzz-push-gateway/src/model.rs @@ -12,14 +12,14 @@ pub const WIRE_VERSION: u8 = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AppProfile { - BuzzIosProduction, - BuzzIosSandbox, + BuzzIosDogfood, + BuzzIosAppStore, } impl AppProfile { pub const fn as_str(self) -> &'static str { match self { - Self::BuzzIosProduction => "buzz-ios-production", - Self::BuzzIosSandbox => "buzz-ios-sandbox", + Self::BuzzIosDogfood => "buzz-ios-dogfood", + Self::BuzzIosAppStore => "buzz-ios-app-store", } } } diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index bd69ec25646..a3ea82c8230 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -63,8 +63,8 @@ fn ts(v: DateTime) -> i64 { } fn profile(v: &str) -> Result { match v { - "buzz-ios-production" => Ok(AppProfile::BuzzIosProduction), - "buzz-ios-sandbox" => Ok(AppProfile::BuzzIosSandbox), + "buzz-ios-dogfood" => Ok(AppProfile::BuzzIosDogfood), + "buzz-ios-app-store" => Ok(AppProfile::BuzzIosAppStore), _ => Err(AuthorityError::Unavailable), } } @@ -683,7 +683,7 @@ mod tests { 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-production',$4,$5,1,$6)", + VALUES ($1,$2,$3,0,'buzz-ios-dogfood',$4,$5,1,$6)", ) .bind(Uuid::from_u128(1)) .bind(vec![1u8]) diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-cert-only.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-cert-only.pem new file mode 100644 index 00000000000..dc7e9923a54 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-cert-only.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-encrypted-identity.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-encrypted-identity.pem new file mode 100644 index 00000000000..7461fbe111f --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-encrypted-identity.pem @@ -0,0 +1,19 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBCrxiLXIJU5iHcD0IMS +sRI0AgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQmlIQbhuOv5VUfS6I +MVPLEwSBkNqbXztd0jeDg0nA1RCDPerWJUZqN5i6TtZtLwxLhpfcrDPT0aVEoFLv +dyRLcdzRmYNmHAoEaO0o0nLahGOlu4PlYqEoTahIq/ursix7JV5NhUJUWMFJFTz9 +qgYSTxsvecejzM4SvMMVx5zVhgn/ojMDbocNOA8DfMW/U6gP9AxBV5RqyMGsMcuK +OPn9XCFJsg== +-----END ENCRYPTED PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem new file mode 100644 index 00000000000..f174811712b --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/p0z63nx4o4jOiA0 +AEwfcyxe4NyuSjl0wPYOW5u3SQahRANCAATVJOs+qdG7RX0ma7NcjEyy0tNu8pEu +RWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-key-only.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-key-only.pem new file mode 100644 index 00000000000..7c82d17d611 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-key-only.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/p0z63nx4o4jOiA0 +AEwfcyxe4NyuSjl0wPYOW5u3SQahRANCAATVJOs+qdG7RX0ma7NcjEyy0tNu8pEu +RWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +-----END PRIVATE KEY----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-mismatched-identity.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-mismatched-identity.pem new file mode 100644 index 00000000000..bed75b120f2 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-mismatched-identity.pem @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg18TP8zUw6UBPuIc2 +4zZIQ7TMe4Iu9VtXGxVXMV3PRPqhRANCAASN9Thxojkwcn1d2XN3KswViaVM+tpK +v69Qne0M1q8A6finFJ7chBwu8/G+nFPyYszJZnm6vGwxzxIBEpd9KJT1 +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock new file mode 100644 index 00000000000..d29012deecf --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.lock @@ -0,0 +1,341 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "buzz-app-attest-fixture-generator" +version = "0.1.0" +dependencies = [ + "base64", + "byteorder", + "ciborium", + "openssl", + "sha2", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml new file mode 100644 index 00000000000..2ffc6d243f7 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "buzz-app-attest-fixture-generator" +version = "0.1.0" +edition = "2021" +publish = false + +[workspace] + +[dependencies] +base64 = "0.22" +byteorder = "1.5" +ciborium = "0.2" +openssl = "0.10" +sha2 = "0.10" diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md new file mode 100644 index 00000000000..cf498bf1d68 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/README.md @@ -0,0 +1,18 @@ +# App Attest verifier fixtures + +This standalone crate generates synthetic App Attest fixtures for the gateway's strict verifier tests. It has its own `[workspace]`, does not belong to the repository workspace dependency graph, and must never depend on `appattest`. That separation prevents the dependency's `testing` feature from being unified into the gateway test build, where it would permit the development AAGUID. + +The generator owns the full root, intermediate, and credential certificate chain. It writes Apple's nonce extension OID `1.2.840.113635.100.8.2` and App Attest EKU `1.2.840.113635.100.4.24` directly. Generator correctness is therefore load-bearing. The strict verifier's good-fixture acceptance test is the encoding oracle. Any generator change must pass the full App Attest test floor, and a fixture rejected by the shipped verifier must never be made green by loosening a test. + +`apple-app-attestation-root.pem` is the production pin-control fixture downloaded from [Apple's certificate authority](https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem). Its exact PEM-file SHA-256 must remain `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. + +Regenerate from the repository root: + +```bash +cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- \ + --output-dir crates/buzz-push-gateway/tests/fixtures \ + --good-aaguid appattest \ + --wrong-aaguid appattestdevelop +``` + +The command rewrites `app-attest-good.json`, `app-attest-wrong-aaguid.json`, and `app-attest-wrong-root.json`. Review all fixture changes and run the complete gateway package test suite afterward. diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs new file mode 100644 index 00000000000..fe367fbdaaf --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-generator/src/main.rs @@ -0,0 +1,404 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use byteorder::{BigEndian, ByteOrder}; +use ciborium::{cbor, Value}; +use openssl::{ + asn1::{Asn1Integer, Asn1Object, Asn1OctetString, Asn1Time}, + bn::{BigNum, MsbOption}, + ec::{EcGroup, EcKey, PointConversionForm}, + hash::MessageDigest, + nid::Nid, + pkey::{PKey, Private}, + x509::{ + extension::{BasicConstraints, ExtendedKeyUsage, KeyUsage}, + X509Builder, X509Extension, X509Name, X509NameBuilder, X509, + }, +}; +use sha2::{Digest, Sha256}; + +const APP_ID: &str = "TEAMID.xyz.buzz.mobile"; +const CHALLENGE: &str = "buzz-app-attest-strict-verifier-fixture"; +const CERT_VALIDITY_DAYS: u32 = 7_305; +const REGENERATION_COMMAND: &str = "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop"; + +struct Args { + output_dir: PathBuf, + good_aaguid: String, + wrong_aaguid: String, +} + +struct CertificateAuthority { + root_cert: X509, + intermediate_cert: X509, + intermediate_key: PKey, +} + +struct Fixture { + attestation_b64: String, + key_id_b64: String, + root_cert_pem: String, + leaf_not_after: String, +} + +fn main() { + let args = parse_args(); + validate_aaguid(&args.good_aaguid); + validate_aaguid(&args.wrong_aaguid); + fs::create_dir_all(&args.output_dir).expect("create fixture output directory"); + + let primary_ca = CertificateAuthority::generate("Buzz App Attest Fixture Root"); + let good = build_fixture(&primary_ca, &args.good_aaguid); + let wrong_aaguid = build_fixture(&primary_ca, &args.wrong_aaguid); + + let unrelated_ca = CertificateAuthority::generate("Unrelated App Attest Fixture Root"); + let wrong_root = build_fixture(&unrelated_ca, &args.good_aaguid); + + write_fixture( + &args.output_dir, + "app-attest-good.json", + "Valid synthetic Apple App Attest attestation for the gateway strict-verifier acceptance control.", + &args.good_aaguid, + &good, + ); + write_fixture( + &args.output_dir, + "app-attest-wrong-aaguid.json", + "Synthetic attestation with a development AAGUID and a correctly recomputed nonce; the strict verifier must report InvalidAAGUID.", + &args.wrong_aaguid, + &wrong_aaguid, + ); + write_fixture( + &args.output_dir, + "app-attest-wrong-root.json", + "Internally valid synthetic attestation signed by an unrelated root; the verifier configured with the good fixture root must reject it.", + &args.good_aaguid, + &wrong_root, + ); +} + +fn parse_args() -> Args { + let mut output_dir = None; + let mut good_aaguid = None; + let mut wrong_aaguid = None; + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + let value = args + .next() + .unwrap_or_else(|| panic!("missing value for {arg}")); + match arg.as_str() { + "--output-dir" => output_dir = Some(PathBuf::from(value)), + "--good-aaguid" => good_aaguid = Some(value), + "--wrong-aaguid" => wrong_aaguid = Some(value), + _ => panic!("unknown argument {arg}"), + } + } + Args { + output_dir: output_dir.expect("--output-dir is required"), + good_aaguid: good_aaguid.expect("--good-aaguid is required"), + wrong_aaguid: wrong_aaguid.expect("--wrong-aaguid is required"), + } +} + +fn validate_aaguid(aaguid: &str) { + assert!( + !aaguid.is_empty() && aaguid.len() <= 16 && aaguid.is_ascii(), + "AAGUID must be 1 to 16 ASCII bytes" + ); +} + +impl CertificateAuthority { + fn generate(root_common_name: &str) -> Self { + let root_key = p384_key(); + let root_name = name(root_common_name); + let root_cert = certificate( + &root_name, + &root_name, + &root_key, + &root_key, + MessageDigest::sha384(), + true, + None, + ); + + let intermediate_key = p256_key(); + let intermediate_name = name("Buzz App Attest Fixture Intermediate"); + let intermediate_cert = certificate( + &intermediate_name, + root_cert.subject_name(), + &intermediate_key, + &root_key, + MessageDigest::sha384(), + true, + None, + ); + + Self { + root_cert, + intermediate_cert, + intermediate_key, + } + } +} + +fn build_fixture(ca: &CertificateAuthority, aaguid_value: &str) -> Fixture { + let device_key = p256_key(); + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).expect("P-256 group"); + let ec_key = device_key.ec_key().expect("device EC key"); + let mut context = openssl::bn::BigNumContext::new().expect("bignum context"); + let public_key = ec_key + .public_key() + .to_bytes(&group, PointConversionForm::UNCOMPRESSED, &mut context) + .expect("serialize device public key"); + let key_id = Sha256::digest(&public_key); + let key_id_b64 = STANDARD.encode(key_id); + + let mut aaguid = [0_u8; 16]; + aaguid[..aaguid_value.len()].copy_from_slice(aaguid_value.as_bytes()); + let mut auth_data = Vec::with_capacity(87); + auth_data.extend_from_slice(&Sha256::digest(APP_ID.as_bytes())); + auth_data.push(0x41); + auth_data.extend_from_slice(&[0_u8; 4]); + auth_data.extend_from_slice(&aaguid); + let mut credential_length = [0_u8; 2]; + BigEndian::write_u16(&mut credential_length, key_id.len() as u16); + auth_data.extend_from_slice(&credential_length); + auth_data.extend_from_slice(&key_id); + + let mut nonce_input = auth_data.clone(); + nonce_input.extend_from_slice(&Sha256::digest(CHALLENGE.as_bytes())); + let nonce = Sha256::digest(nonce_input); + + let leaf_name = name("Buzz App Attest Fixture Credential"); + let leaf_cert = certificate( + &leaf_name, + ca.intermediate_cert.subject_name(), + &device_key, + &ca.intermediate_key, + MessageDigest::sha256(), + false, + Some(&nonce), + ); + let leaf_der = leaf_cert.to_der().expect("encode credential certificate"); + let intermediate_der = ca + .intermediate_cert + .to_der() + .expect("encode intermediate certificate"); + let value = cbor!({ + "fmt" => "apple-appattest", + "attStmt" => { + "x5c" => [ + Value::Bytes(leaf_der), + Value::Bytes(intermediate_der) + ], + "receipt" => Value::Bytes(Vec::new()) + }, + "authData" => Value::Bytes(auth_data) + }) + .expect("build attestation CBOR value"); + let mut cbor = Vec::new(); + ciborium::into_writer(&value, &mut cbor).expect("encode attestation CBOR"); + + Fixture { + attestation_b64: STANDARD.encode(cbor), + key_id_b64, + root_cert_pem: String::from_utf8( + ca.root_cert.to_pem().expect("encode root certificate PEM"), + ) + .expect("root PEM is UTF-8"), + leaf_not_after: leaf_cert.not_after().to_string(), + } +} + +fn certificate( + subject: &X509Name, + issuer: &openssl::x509::X509NameRef, + subject_key: &PKey, + issuer_key: &PKey, + signature_digest: MessageDigest, + is_ca: bool, + nonce: Option<&[u8]>, +) -> X509 { + let mut builder = X509Builder::new().expect("create certificate builder"); + builder.set_version(2).expect("set X.509 version"); + builder + .set_serial_number(&serial_number()) + .expect("set certificate serial"); + builder + .set_subject_name(subject) + .expect("set certificate subject"); + builder + .set_issuer_name(issuer) + .expect("set certificate issuer"); + builder + .set_pubkey(subject_key) + .expect("set certificate public key"); + builder + .set_not_before(&Asn1Time::days_from_now(0).expect("build notBefore")) + .expect("set notBefore"); + builder + .set_not_after(&Asn1Time::days_from_now(CERT_VALIDITY_DAYS).expect("build long notAfter")) + .expect("set notAfter"); + + if is_ca { + builder + .append_extension( + BasicConstraints::new() + .critical() + .ca() + .build() + .expect("build CA constraints"), + ) + .expect("append CA constraints"); + builder + .append_extension( + KeyUsage::new() + .critical() + .key_cert_sign() + .crl_sign() + .build() + .expect("build CA key usage"), + ) + .expect("append CA key usage"); + } else { + builder + .append_extension( + BasicConstraints::new() + .critical() + .build() + .expect("build leaf constraints"), + ) + .expect("append leaf constraints"); + builder + .append_extension( + ExtendedKeyUsage::new() + .other("1.2.840.113635.100.4.24") + .build() + .expect("build App Attest EKU"), + ) + .expect("append App Attest EKU"); + + let nonce = nonce.expect("credential certificate nonce"); + assert_eq!(nonce.len(), 32, "App Attest nonce must be 32 bytes"); + let mut extension_value = Vec::with_capacity(38); + extension_value.extend_from_slice(&[0x30, 0x24, 0xa1, 0x22, 0x04, 0x20]); + extension_value.extend_from_slice(nonce); + let oid = Asn1Object::from_str("1.2.840.113635.100.8.2") + .expect("parse App Attest nonce extension OID"); + let octets = Asn1OctetString::new_from_bytes(&extension_value) + .expect("encode App Attest nonce extension"); + builder + .append_extension( + X509Extension::new_from_der(&oid, false, &octets) + .expect("build App Attest nonce extension"), + ) + .expect("append App Attest nonce extension"); + } + + builder + .sign(issuer_key, signature_digest) + .expect("sign certificate"); + builder.build() +} + +fn p256_key() -> PKey { + ec_key(Nid::X9_62_PRIME256V1) +} + +fn p384_key() -> PKey { + ec_key(Nid::SECP384R1) +} + +fn ec_key(curve: Nid) -> PKey { + let group = EcGroup::from_curve_name(curve).expect("EC group"); + PKey::from_ec_key(EcKey::generate(&group).expect("generate EC key")).expect("wrap EC key") +} + +fn name(common_name: &str) -> X509Name { + let mut builder = X509NameBuilder::new().expect("create X.509 name builder"); + builder + .append_entry_by_text("CN", common_name) + .expect("set common name"); + builder + .append_entry_by_text("O", "Buzz") + .expect("set organization"); + builder.build() +} + +fn serial_number() -> Asn1Integer { + let mut number = BigNum::new().expect("create certificate serial"); + number + .rand(128, MsbOption::MAYBE_ZERO, false) + .expect("generate certificate serial"); + Asn1Integer::from_bn(&number).expect("convert certificate serial") +} + +fn write_fixture( + output_dir: &Path, + file_name: &str, + description: &str, + aaguid: &str, + fixture: &Fixture, +) { + let generated_at = generation_date(); + let json = format!( + concat!( + "{{\n", + " \"description\": \"{}\",\n", + " \"generator\": \"crates/buzz-push-gateway/tests/fixtures/app-attest-generator\",\n", + " \"generated_at\": \"{}\",\n", + " \"regeneration_command\": \"{}\",\n", + " \"app_id\": \"{}\",\n", + " \"challenge\": \"{}\",\n", + " \"aaguid\": \"{}\",\n", + " \"leaf_not_after\": \"{}\",\n", + " \"attestation_b64\": \"{}\",\n", + " \"key_id_b64\": \"{}\",\n", + " \"root_cert_pem\": \"{}\"\n", + "}}\n" + ), + json_escape(description), + generated_at, + json_escape(REGENERATION_COMMAND), + APP_ID, + CHALLENGE, + aaguid, + json_escape(&fixture.leaf_not_after), + fixture.attestation_b64, + fixture.key_id_b64, + json_escape(&fixture.root_cert_pem), + ); + fs::write(output_dir.join(file_name), json).expect("write fixture JSON"); +} + +fn generation_date() -> String { + let output = std::process::Command::new("date") + .args(["-u", "+%Y-%m-%d"]) + .output() + .expect("run date for fixture metadata"); + assert!(output.status.success(), "date command failed"); + String::from_utf8(output.stdout) + .expect("date output is UTF-8") + .trim() + .to_owned() +} + +fn json_escape(value: &str) -> String { + value + .chars() + .flat_map(|character| match character { + '\\' => "\\\\".chars().collect::>(), + '"' => "\\\"".chars().collect(), + '\n' => "\\n".chars().collect(), + '\r' => "\\r".chars().collect(), + '\t' => "\\t".chars().collect(), + value if value.is_control() => { + panic!("unsupported control character in fixture metadata") + } + value => vec![value], + }) + .collect() +} diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json new file mode 100644 index 00000000000..5c076ba951d --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json @@ -0,0 +1,13 @@ +{ + "description": "Valid synthetic Apple App Attest attestation for the gateway strict-verifier acceptance control.", + "generator": "crates/buzz-push-gateway/tests/fixtures/app-attest-generator", + "generated_at": "2026-08-02", + "regeneration_command": "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop", + "app_id": "TEAMID.xyz.buzz.mobile", + "challenge": "buzz-app-attest-strict-verifier-fixture", + "aaguid": "appattest", + "leaf_not_after": "Aug 2 05:27:02 2046 GMT", + "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdgwggHUMIIBeqADAgECAhEA1hkzMVx4LIlx2Z04+dq+DjAKBggqhkjOPQQDAjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA8MSswKQYDVQQDDCJCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBDcmVkZW50aWFsMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPnUoIjO//gCI6cvjfmAw62OnnngGVoId2q7MQYG//94tXIX2tIZChUe1y/spRzJqxLo0JNm7d9QKdoVuLNBpfaNbMFkwDAYDVR0TAQH/BAIwADAUBgNVHSUEDTALBgkqhkiG92NkBBgwMwYJKoZIhvdjZAgCBCYwJKEiBCDi01h8mHF6AJkdlwJoO7ieXb9TDEttdsV48n1Jd57tIDAKBggqhkjOPQQDAgNIADBFAiEAmyNVz7oG03YWXBP55xcqJ1xrwv7INxQmSKjr/lrrXKwCIEGS9+8qhYxQfZa1q/jcegDlNxphatVVqx5j8cQbjNU2WQG6MIIBtjCCATugAwIBAgIQJj6YcsuecIX6zF/ZFQ6wzDAKBggqhkjOPQQDAzA2MSUwIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowPjEtMCsGA1UEAwwkQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgSW50ZXJtZWRpYXRlMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfGKED5L/Nh0lvKRJAllDU01J6pZhqYBV/a7HRTphUIkIhW0Jc/Q2BplGB+vrMgUG+QX9eG8k7VvRZjov/m7gbaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDaQAwZgIxAONcQ0m5yYfK4ILWwnRWAZjhQg/ZrwiRY3VEBzkAc082FXwp0mqMjXwicSt/ibULFgIxAKFayHKDgusCMjLMPkoIYbOI2jnR+TY8Vftq89b33qLQ2EebRB1PGDld2mvVY01OU2dyZWNlaXB0QGhhdXRoRGF0YVhXH5nFfKMZs8qsLEqZv4n7atEJxvG0oHWjDbycL/O5tJlBAAAAAGFwcGF0dGVzdAAAAAAAAAAAIOtFw/nPMzM0gQAeS/gQ1R2aF7oMMjXIx08QJN8q0cuk", + "key_id_b64": "60XD+c8zMzSBAB5L+BDVHZoXugwyNcjHTxAk3yrRy6Q=", + "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n" +} diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json new file mode 100644 index 00000000000..4b8a5737b7c --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json @@ -0,0 +1,13 @@ +{ + "description": "Synthetic attestation with a development AAGUID and a correctly recomputed nonce; the strict verifier must report InvalidAAGUID.", + "generator": "crates/buzz-push-gateway/tests/fixtures/app-attest-generator", + "generated_at": "2026-08-02", + "regeneration_command": "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop", + "app_id": "TEAMID.xyz.buzz.mobile", + "challenge": "buzz-app-attest-strict-verifier-fixture", + "aaguid": "appattestdevelop", + "leaf_not_after": "Aug 2 05:27:02 2046 GMT", + "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhAXdDyYByLYxE4WftXjOFC1MAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ8ZGvDc7xMJINZw6mLHRU6xr1kFY+vn+PRZYIMypdlYb99U/l8VCK9zWQt+xXSEAyNvzdcZiom5N/fKuAI5xh/o1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEILEfYIC8xsY+hZnqOrQF1PpWR3VioqnjjQwo5/YmtAwRMAoGCCqGSM49BAMCA0gAMEUCIQCpOzhfo94xcJ0ojQki6wxpOdORPsNwXtZz+eByIhtwlwIgPr71d/DiOaQ3Jd9jDaiCFrzozcR5owB0kaKRzvFuBv1ZAbowggG2MIIBO6ADAgECAhAmPphyy55whfrMX9kVDrDMMAoGCCqGSM49BAMDMDYxJTAjBgNVBAMMHEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR8YoQPkv82HSW8pEkCWUNTTUnqlmGpgFX9rsdFOmFQiQiFbQlz9DYGmUYH6+syBQb5Bf14byTtW9FmOi/+buBtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNpADBmAjEA41xDSbnJh8rggtbCdFYBmOFCD9mvCJFjdUQHOQBzTzYVfCnSaoyNfCJxK3+JtQsWAjEAoVrIcoOC6wIyMsw+Sghhs4jaOdH5NjxV+2rz1vfeotDYR5tEHU8YOV3aa9VjTU5TZ3JlY2VpcHRAaGF1dGhEYXRhWFcfmcV8oxmzyqwsSpm/iftq0QnG8bSgdaMNvJwv87m0mUEAAAAAYXBwYXR0ZXN0ZGV2ZWxvcAAg6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=", + "key_id_b64": "6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=", + "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n" +} diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json new file mode 100644 index 00000000000..c709c118e44 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json @@ -0,0 +1,13 @@ +{ + "description": "Internally valid synthetic attestation signed by an unrelated root; the verifier configured with the good fixture root must reject it.", + "generator": "crates/buzz-push-gateway/tests/fixtures/app-attest-generator", + "generated_at": "2026-08-02", + "regeneration_command": "cargo run --manifest-path crates/buzz-push-gateway/tests/fixtures/app-attest-generator/Cargo.toml -- --output-dir crates/buzz-push-gateway/tests/fixtures --good-aaguid appattest --wrong-aaguid appattestdevelop", + "app_id": "TEAMID.xyz.buzz.mobile", + "challenge": "buzz-app-attest-strict-verifier-fixture", + "aaguid": "appattest", + "leaf_not_after": "Aug 2 05:27:02 2046 GMT", + "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhBGe4kbr8X3vBBmRW24fEPWMAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3NUd9f8Ma88b5fiKPmvgL0akkZfv3Q5v2jJMGVQ+pDY2ZFkZTQnzTfAPydFBFtVQE9HpPLlx22e/8eixSUFdLo1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEIF7PDSiNaYyhbJlVGsubqOBUPUSS4sT5PJ0Ri8mGDRjSMAoGCCqGSM49BAMCA0gAMEUCIQCSjdrbcQurd+avRl+OcRIZPusoJBNVGLun3Rda9tJ5NwIgOFEcGxdOZi3atz7Nwzwe409oVcu4GdXOVo9N86pOu8dZAb8wggG7MIIBQaADAgECAhEAx1cRnQUJhJKCUll92sLeGDAKBggqhkjOPQQDAzA7MSowKAYDVQQDDCFVbnJlbGF0ZWQgQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASz7MX/nc9MaCmjSQ3f+L8SCsgNdFEcDyZ7FxREEPu4bGUujA+P5exSwDuA8L64WrznNITC1J8sZ98VZ/tTNWFtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjEA8ABjGCavBGyl6FgO9u58hV/xzRnhdlFiTUPiN/XCvmfxDkOyYwzLk06/k4JmdqfCAjBADKJsa+9138UAMZgU8iYWTOY+FO96DHsdC+8H9vBoLBE/DxzQHsX2Wd/DEbggUGJncmVjZWlwdEBoYXV0aERhdGFYVx+ZxXyjGbPKrCxKmb+J+2rRCcbxtKB1ow28nC/zubSZQQAAAABhcHBhdHRlc3QAAAAAAAAAACCvcDv+nttQP9RSSwBycpsL+NiE13xuEsfU7iKqeRaTsQ==", + "key_id_b64": "r3A7/p7bUD/UUksAcnKbC/jYhNd8bhLH1O4iqnkWk7E=", + "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIB1DCCAVugAwIBAgIRALE3l3fzQ4wPjIL/IjBs02IwCgYIKoZIzj0EAwMwOzEq\nMCgGA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYD\nVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowOzEqMCgG\nA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQK\nDARCdXp6MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEPa8SWuDIcjNVDwTXlTQnWbKj\n5Vt8TCiGGH0CiSJajPOlevvjHBEYuVHf7bFYa5N/7OzXQ3qkZomCyizJ6nc5tBEN\nGL3rkz7vZjb9J3QPfixkBwyUHFHmx1WJ84fgAYcDoyMwITAPBgNVHRMBAf8EBTAD\nAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjAMO0cvuHHJSqWj\n4DxJorq8LH7VH9ILTGjcZmz91rLlO7w4oDqiewFQE+GVFl9boekCMBhaa0a/WiW2\nyf2j5d04SOkXREM1NkbHsd1yH1jqSOCuj6PU3Z6zDSSXy1z3HjQIBg==\n-----END CERTIFICATE-----\n" +} diff --git a/crates/buzz-push-gateway/tests/fixtures/apple-app-attestation-root.pem b/crates/buzz-push-gateway/tests/fixtures/apple-app-attestation-root.pem new file mode 100644 index 00000000000..4cff2277b51 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apple-app-attestation-root.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw +JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK +QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa +Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv +biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y +bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh +NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au +Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw +CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn +53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV +oyFraWVIyd/dganmrduC1bmTBGwD +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json b/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json new file mode 100644 index 00000000000..27b84035d6c --- /dev/null +++ b/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json @@ -0,0 +1,48 @@ +{ + "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).", + "inputs": { + "challenge_id": "11111111-1111-4111-8111-111111111111", + "installation_handle": "22222222-2222-4222-8222-222222222222", + "challenge": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", + "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, + "expires_at": 1752624000 + }, + "vectors": [ + { + "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" + }, + { + "name": "delegate", + "domain": "buzz.push.delegate.v1", + "transcript": "buzz.push.delegate.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/delegations\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"generation\":1,\"relay_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"not_before\":1752620000,\"expires_at\":1752624000}", + "sha256": "7466177cc2dc2a4f9a075fdbb461531692fc858778a171a5862b855cccfaa059" + }, + { + "name": "rotate_endpoint", + "domain": "buzz.push.rotate-endpoint.v1", + "transcript": "buzz.push.rotate-endpoint.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations/endpoint\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"new_endpoint_epoch\":2,\"endpoint\":\"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20\"}", + "sha256": "601aba0c8d4021ddf97ce1e434b9c7ad1e051bf02a44929aaebd8c6bd724e7b3" + }, + { + "name": "revoke_delegation", + "domain": "buzz.push.revoke-delegation.v1", + "transcript": "buzz.push.revoke-delegation.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/delegations/revoke\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"relay_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"generation\":2}", + "sha256": "d6bcd4b25235adcb519ef189820b08dd0386fc752fd4e4c77bc3ffb7a519a84a" + }, + { + "name": "revoke_installation", + "domain": "buzz.push.revoke-installation.v1", + "transcript": "buzz.push.revoke-installation.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations/revoke\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"new_endpoint_epoch\":2}", + "sha256": "0ba51827af6586a5e1230e9b770b99544fb342efb55db3ab1ce499cf24a893c8" + } + ] +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 037c6b1dd3d..c8034e87c10 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -269,10 +269,14 @@ pub struct Config { /// Used to authenticate internal policy endpoint requests. pub git_hook_hmac_secret: String, + /// Whether NIP-PL push discovery, lease acceptance, matching, and delivery + /// are enabled for this deployment. Defaults to false. + pub push_enabled: bool, /// Descriptor key identifier accepted in kind:30350 `exec` tags. pub push_executor_key_id: String, /// Exact HTTPS gateway endpoint used to submit client-authorized APNs delivery capabilities. - /// Push lease support is disabled when unset. + /// An absent setting selects the canonical Buzz gateway. An explicitly + /// empty setting is allowed only while push is disabled. pub push_gateway_delivery_url: Option, /// Hard timeout for one gateway delivery request. pub push_gateway_timeout: Duration, @@ -859,6 +863,7 @@ impl Config { let secret: [u8; 32] = rand::random(); hex::encode(secret) }); + let push_enabled = parse_bool("BUZZ_PUSH_ENABLED", false)?; let push_executor_key_id = std::env::var("BUZZ_PUSH_EXECUTOR_KEY_ID").unwrap_or_else(|_| "relay-v1".to_string()); if push_executor_key_id.is_empty() || push_executor_key_id.len() > 64 { @@ -867,6 +872,12 @@ impl Config { )); } let push_gateway_delivery_url = match std::env::var("BUZZ_PUSH_GATEWAY_DELIVERY_URL") { + Ok(raw) if raw.trim().is_empty() && push_enabled => { + return Err(ConfigError::InvalidValue( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must not be empty when BUZZ_PUSH_ENABLED=true" + .to_string(), + )); + } Ok(raw) if raw.trim().is_empty() => None, Ok(raw) => Some(parse_push_gateway_delivery_url(&raw)?), Err(_) => Some(parse_push_gateway_delivery_url( @@ -1034,6 +1045,7 @@ impl Config { git_max_repos_per_pubkey, git_max_concurrent_ops, git_hook_hmac_secret, + push_enabled, push_executor_key_id, push_gateway_delivery_url, push_gateway_timeout, @@ -1577,11 +1589,25 @@ mod tests { } #[test] - fn push_gateway_defaults_to_buzz_and_can_be_disabled() { + fn push_is_opt_in_and_gateway_defaults_to_buzz() { let _guard = ENV_MUTEX.lock().unwrap(); + let previous_enabled = std::env::var_os("BUZZ_PUSH_ENABLED"); let previous = std::env::var_os("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); + std::env::remove_var("BUZZ_PUSH_ENABLED"); std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); let config = Config::from_env().expect("default config"); + assert!(!config.push_enabled); + assert_eq!( + config + .push_gateway_delivery_url + .as_ref() + .map(url::Url::as_str), + Some(DEFAULT_PUSH_GATEWAY_DELIVERY_URL) + ); + + std::env::set_var("BUZZ_PUSH_ENABLED", "true"); + let config = Config::from_env().expect("enabled push config"); + assert!(config.push_enabled); assert_eq!( config .push_gateway_delivery_url @@ -1591,9 +1617,22 @@ mod tests { ); std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", ""); + let result = Config::from_env(); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("must not be empty") + )); + + std::env::set_var("BUZZ_PUSH_ENABLED", "false"); let config = Config::from_env().expect("disabled push config"); assert!(config.push_gateway_delivery_url.is_none()); + if let Some(value) = previous_enabled { + std::env::set_var("BUZZ_PUSH_ENABLED", value); + } else { + std::env::remove_var("BUZZ_PUSH_ENABLED"); + } if let Some(value) = previous { std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", value); } else { @@ -1601,6 +1640,24 @@ mod tests { } } + #[test] + fn invalid_push_enabled_value_is_rejected() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_PUSH_ENABLED"); + std::env::set_var("BUZZ_PUSH_ENABLED", "sometimes"); + let result = Config::from_env(); + if let Some(value) = previous { + std::env::set_var("BUZZ_PUSH_ENABLED", value); + } else { + std::env::remove_var("BUZZ_PUSH_ENABLED"); + } + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_PUSH_ENABLED") + )); + } + #[test] fn push_gateway_url_is_exact_and_fail_closed() { assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok()); diff --git a/crates/buzz-relay/src/handlers/push_lease.rs b/crates/buzz-relay/src/handlers/push_lease.rs index ec56a096fdc..d3e0188dbbb 100644 --- a/crates/buzz-relay/src/handlers/push_lease.rs +++ b/crates/buzz-relay/src/handlers/push_lease.rs @@ -12,7 +12,10 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Number, Value}; use sha2::Digest as _; -pub(crate) const PUSH_KINDS: &[u64] = &[7, 9, 1059, 40007, 46010]; +/// 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. +pub(crate) const PUSH_KINDS: &[u64] = &[9, 40_002, 45_001, 45_003]; pub(crate) const URGENT_KINDS: &[u64] = &[]; /// NIP-PL addressable push-lease event kind. @@ -477,7 +480,7 @@ pub async fn accept( const MAX_CONTENT: usize = 65_536; const MAX_PLAINTEXT: usize = 32_768; const MAX_ACTIVE_LEASES: i64 = 16; - if state.config.push_gateway_delivery_url.is_none() { + if !state.config.push_enabled { return Err(AcceptError::Validation("push not supported".to_string())); } let envelope = validate_envelope(event, now, ALLOWED_SKEW, MAX_LEASE_TTL, MAX_CONTENT)?; @@ -498,11 +501,11 @@ pub async fn accept( author_hex: &author_hex, app_profiles: &[ AppProfile { - id: "buzz-ios-production", + id: "buzz-ios-dogfood", transport: "apns", }, AppProfile { - id: "buzz-ios-sandbox", + id: "buzz-ios-app-store", transport: "apns", }, ], @@ -531,25 +534,29 @@ pub async fn accept( let subscriptions; let capability; let active = if body.active { - let endpoint = body.endpoint.as_deref().expect("validated active endpoint"); - endpoint_hash = sha2::Sha256::digest(endpoint.as_bytes()).to_vec(); - let max_class = body + let endpoint = body + .endpoint + .as_deref() + .ok_or_else(|| "active lease is missing endpoint".to_string())?; + let body_subscriptions = body .subscriptions .as_ref() - .expect("validated subscriptions") + .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() .map(|sub| sub.class.as_str()) .max_by_key(|class| class_rank(class)) - .expect("non-empty subscriptions"); + .ok_or_else(|| "active lease has no subscriptions".to_string())?; capability = endpoint.to_owned(); - subscriptions = serde_json::to_value( - body.subscriptions - .as_ref() - .expect("validated subscriptions"), - ) - .map_err(|_| "invalid subscriptions".to_string())?; + subscriptions = serde_json::to_value(body_subscriptions) + .map_err(|_| "invalid subscriptions".to_string())?; Some(buzz_db::push::ActiveLease { - app_profile: body.app_profile.as_deref().expect("validated profile"), + app_profile, endpoint_hash: &endpoint_hash, endpoint_grant: &capability, max_class, @@ -702,7 +709,7 @@ mod tests { .collect::>() .join(", "); let predicate = format!("NEW.kind IN ({kinds})"); - let migration = include_str!("../../../../migrations/0018_push_match_queue.sql"); + let migration = include_str!("../../../../migrations/0033_push_message_kinds.sql"); assert!( migration.contains(&predicate), "migration trigger must use PUSH_KINDS exactly: {predicate}" diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 566b684f830..77462b3f289 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -150,6 +150,7 @@ async fn main() -> anyhow::Result<()> { metrics_port = config.metrics_port, max_frame_bytes = config.max_frame_bytes, audit_enabled = config.audit_enabled, + push_enabled = config.push_enabled, "Config loaded" ); @@ -157,6 +158,7 @@ async fn main() -> anyhow::Result<()> { let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs); relay_metrics::install(config.metrics_port, usage_idle_timeout_secs); metrics::gauge!("buzz_audit_enabled").set(if config.audit_enabled { 1.0 } else { 0.0 }); + metrics::gauge!("buzz_push_enabled").set(if config.push_enabled { 1.0 } else { 0.0 }); info!( port = config.metrics_port, idle_timeout_secs = usage_idle_timeout_secs, @@ -739,15 +741,16 @@ async fn main() -> anyhow::Result<()> { }); } - // NIP-PL matcher and worker are enabled as one unit. Lease acceptance is - // already disabled without the exact gateway URL, so discovery and runtime - // cannot advertise or accumulate work for an undeliverable configuration. - if state.config.push_gateway_delivery_url.is_some() { + // NIP-PL matcher and worker are enabled as one unit behind the explicit + // deployment opt-in. The gateway URL alone never enables push. + if state.config.push_enabled { tokio::spawn(buzz_relay::push_runtime::run_matcher(Arc::clone(&state))); tokio::spawn(buzz_relay::push_runtime::run_delivery_worker(Arc::clone( &state, ))); info!("NIP-PL push matcher and delivery worker started"); + } else { + info!("NIP-PL push disabled by BUZZ_PUSH_ENABLED"); } // NIP-ER reminder scheduler — polls for due reminders and publishes them diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 2575ddd7baa..3a780db787d 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -207,8 +207,8 @@ fn push_descriptor( "current": true }], "app_profiles": [ - {"id": "buzz-ios-production", "transport": "apns"}, - {"id": "buzz-ios-sandbox", "transport": "apns"} + {"id": "buzz-ios-dogfood", "transport": "apns"}, + {"id": "buzz-ios-app-store", "transport": "apns"} ], "push_kinds": crate::handlers::push_lease::PUSH_KINDS, "urgent_kinds": crate::handlers::push_lease::URGENT_KINDS, @@ -247,7 +247,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st state.config.max_frame_bytes, state.config.pairing_relay_url.as_deref(), ); - let tenant_host = if state.config.push_gateway_delivery_url.is_some() { + let tenant_host = if state.config.push_enabled { crate::tenant::bind_community(&state.db, raw_host) .await .ok() @@ -256,7 +256,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st None }; if let Some(push) = push_descriptor( - state.config.push_gateway_delivery_url.is_some(), + state.config.push_enabled, &state.config.relay_url, &state.config.push_executor_key_id, &state.relay_keypair, diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 4946b248c65..2b16e05b34b 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -1,6 +1,9 @@ //! Durable NIP-PL event matcher and gateway delivery worker. -use std::{sync::Arc, time::Duration}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; use base64::Engine as _; use buzz_core::filter::{filters_match, reader_authorized_for_event}; @@ -131,6 +134,8 @@ async fn process_match_batch(state: &AppState, batch: buzz_db::push::ClaimedMatc // the whole batch for retry. Jobs that keep failing are reaped by // the periodic sweep once their attempts are exhausted. warn!(%community, "push match context load failed: {e}"); + metrics::counter!("buzz_push_match_jobs_total", "result" => "context_error") + .increment(batch.jobs.len() as u64); let ids: Vec> = batch .jobs .iter() @@ -158,14 +163,26 @@ async fn process_match_batch(state: &AppState, batch: buzz_db::push::ClaimedMatc let mut pending = Vec::new(); let mut wakes: Vec = Vec::new(); for job in &batch.jobs { + let match_queue_seconds = Utc::now() + .signed_duration_since(job.event.received_at) + .num_milliseconds() + .max(0) as f64 + / 1_000.0; + metrics::histogram!("buzz_push_match_queue_seconds").record(match_queue_seconds); let event_id = job.event.event.id.as_bytes().to_vec(); match match_job(job, &context) { - Ok(job_wakes) if job_wakes.is_empty() => completed.push(event_id), + Ok(job_wakes) if job_wakes.is_empty() => { + metrics::counter!("buzz_push_match_jobs_total", "result" => "unmatched") + .increment(1); + completed.push(event_id); + } Ok(job_wakes) => { + metrics::counter!("buzz_push_match_jobs_total", "result" => "matched").increment(1); pending.push((event_id, job.attempt)); wakes.extend(job_wakes); } Err(e) => { + metrics::counter!("buzz_push_match_jobs_total", "result" => "error").increment(1); warn!(event_id=%job.event.event.id, attempt=job.attempt, "push match failed: {e}"); if job.attempt >= buzz_db::push::MAX_MATCH_ATTEMPTS { // A poison event/lease must not retry forever or pin @@ -182,8 +199,19 @@ async fn process_match_batch(state: &AppState, batch: buzz_db::push::ClaimedMatc // transaction sends the contributing jobs back for an idempotent rematch // (the outbox dedup key absorbs any wakes that did commit elsewhere). match state.db.enqueue_push_wakes(community, &wakes).await { - Ok(_) => completed.extend(pending.into_iter().map(|(event_id, _)| event_id)), + Ok(outcomes) => { + for outcome in outcomes { + let result = match outcome { + buzz_db::push::EnqueueWakeOutcome::Enqueued(_) => "enqueued", + buzz_db::push::EnqueueWakeOutcome::Duplicate(_) => "duplicate", + buzz_db::push::EnqueueWakeOutcome::InactiveLease => "inactive_lease", + }; + metrics::counter!("buzz_push_wakes_total", "result" => result).increment(1); + } + completed.extend(pending.into_iter().map(|(event_id, _)| event_id)); + } Err(e) => { + metrics::counter!("buzz_push_wake_enqueue_errors_total").increment(1); warn!(%community, "push wake batch enqueue failed: {e}"); for (event_id, attempt) in pending { if attempt >= buzz_db::push::MAX_MATCH_ATTEMPTS { @@ -310,10 +338,17 @@ fn push_filter_authorized_for_event( /// Continuously claim due wakes and deliver them through the push gateway. pub async fn run_delivery_worker(state: Arc) { - let http = reqwest::Client::builder() + let http = match reqwest::Client::builder() .timeout(state.config.push_gateway_timeout) .build() - .expect("push HTTP client"); + { + Ok(http) => http, + Err(error) => { + error!(%error, "push HTTP client initialization failed"); + record_delivery("configuration_error"); + return; + } + }; let mut idle_delay = Duration::from_millis(500); loop { let mut found = false; @@ -362,13 +397,23 @@ async fn deliver_one( .db .fail_push_wake(claimed.community, claimed.id, claimed.claim_id) .await; + record_delivery("suppressed"); return; } Err(e) => { warn!(wake=%claimed.id, "push revalidation failed: {e}"); + record_delivery("worker_error"); return; } }; + if outcome.attempt == 1 { + let wake_queue_seconds = Utc::now() + .signed_duration_since(outcome.queued_at) + .num_milliseconds() + .max(0) as f64 + / 1_000.0; + metrics::histogram!("buzz_push_wake_queue_seconds").record(wake_queue_seconds); + } if let Some(channel) = outcome.channel_id { match state .db @@ -381,6 +426,7 @@ async fn deliver_one( .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("suppressed"); return; } Err(e) => { @@ -394,6 +440,7 @@ async fn deliver_one( Utc::now() + TimeDelta::seconds(2), ) .await; + record_delivery("retry"); return; } } @@ -411,10 +458,12 @@ async fn deliver_one( .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("suppressed"); return; } Err(e) => { warn!(wake=%outcome.id, "final push revalidation failed: {e}"); + record_delivery("worker_error"); return; } }; @@ -432,31 +481,47 @@ async fn deliver_one( .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("suppressed"); return; } }; let Some(url) = state.config.push_gateway_delivery_url.as_ref() else { + record_delivery("configuration_error"); return; }; - let body = delivery_body(&outcome.endpoint_grant, outcome.id, outcome.expires_at); + let body = match delivery_body(&outcome.endpoint_grant, outcome.id, outcome.expires_at) { + Ok(body) => body, + Err(error) => { + warn!(wake=%outcome.id, %error, "push delivery body encoding failed"); + record_delivery("worker_error"); + return; + } + }; let auth = match nip98_header(&state.relay_keypair, url.as_str(), &body) { Ok(auth) => auth, Err(e) => { warn!(wake=%outcome.id, "push auth failed: {e}"); + record_delivery("worker_error"); return; } }; if let Err(error) = serving_write.verify().await { warn!(wake=%outcome.id, %error, "push serving lease lost before delivery"); + record_delivery("suppressed"); return; } - let response = match serving_write + metrics::counter!("buzz_push_gateway_requests_total").increment(1); + let gateway_started = Instant::now(); + let protected = serving_write .protect(send_gateway_request(http, url, body, auth)) - .await - { + .await; + metrics::histogram!("buzz_push_gateway_request_seconds") + .record(gateway_started.elapsed().as_secs_f64()); + let response = match protected { Ok(response) => response, Err(error) => { warn!(wake=%outcome.id, %error, "push serving lease lost during delivery"); + record_delivery("suppressed"); return; } }; @@ -467,12 +532,14 @@ async fn deliver_one( .db .complete_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("accepted"); } _ => { let _ = state .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("failed"); } }, Ok(r) if r.status() == reqwest::StatusCode::GONE => { @@ -500,6 +567,7 @@ async fn deliver_one( .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("invalid_endpoint"); } Ok(r) if r.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE => { let delay = match r.json::().await { @@ -510,10 +578,10 @@ async fn deliver_one( .unwrap_or(2), _ => 2, }; - retry_or_fail(state, &outcome, delay).await; + record_delivery(retry_or_fail(state, &outcome, delay).await); } Ok(r) if r.status() == reqwest::StatusCode::TOO_MANY_REQUESTS => { - retry_or_fail(state, &outcome, 2).await + record_delivery(retry_or_fail(state, &outcome, 2).await); } // A timed-out terminal attempt burns the stable request id. Its replay // is indistinguishable from another invalid-grant 404, but sending a @@ -523,13 +591,17 @@ async fn deliver_one( .db .complete_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("replay_terminal"); + } + Err(e) if e.is_timeout() || e.is_connect() => { + record_delivery(retry_or_fail(state, &outcome, 2).await); } - Err(e) if e.is_timeout() || e.is_connect() => retry_or_fail(state, &outcome, 2).await, _ => { let _ = state .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("failed"); } } if let Err(error) = serving_write.finish().await { @@ -537,14 +609,17 @@ async fn deliver_one( } } -fn delivery_body(endpoint_grant: &str, request_id: uuid::Uuid, expires_at: i64) -> Vec { - serde_json::to_vec(&DeliveryRequest { +fn delivery_body( + endpoint_grant: &str, + request_id: uuid::Uuid, + expires_at: i64, +) -> anyhow::Result> { + Ok(serde_json::to_vec(&DeliveryRequest { v: 1, endpoint_grant, request_id, expires_at, - }) - .expect("closed delivery body") + })?) } async fn send_gateway_request( @@ -561,12 +636,21 @@ async fn send_gateway_request( .await } -async fn retry_or_fail(state: &AppState, wake: &buzz_db::push::ClaimedWake, delay: i64) { +fn record_delivery(outcome: &'static str) { + metrics::counter!("buzz_push_deliveries_total", "outcome" => outcome).increment(1); +} + +async fn retry_or_fail( + state: &AppState, + wake: &buzz_db::push::ClaimedWake, + delay: i64, +) -> &'static str { if wake.attempt >= MAX_ATTEMPTS { let _ = state .db .fail_push_wake(wake.community, wake.id, wake.claim_id) .await; + "exhausted" } else { let secs = delay * (1_i64 << (wake.attempt - 1).clamp(0, 6)); let _ = state @@ -578,6 +662,7 @@ async fn retry_or_fail(state: &AppState, wake: &buzz_db::push::ClaimedWake, dela Utc::now() + TimeDelta::seconds(secs), ) .await; + "retry" } } @@ -675,7 +760,8 @@ mod tests { let keys = nostr::Keys::generate(); let request_id = uuid::Uuid::new_v4(); for _ in 0..2 { - let body = delivery_body("opaque-grant", request_id, Utc::now().timestamp() + 60); + let body = + delivery_body("opaque-grant", request_id, Utc::now().timestamp() + 60).unwrap(); let auth = nip98_header(&keys, url.as_str(), &body).unwrap(); let response = send_gateway_request(&http, &url, body, auth).await.unwrap(); assert!(response.status().is_success()); diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index 38f69dee6dc..588a394b051 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -32,16 +32,27 @@ spec: - { name: BUZZ_PUSH_PUBLIC_DELIVERY_URL, value: {{ .Values.publicDeliveryUrl | quote }} } - { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | quote }} } - { name: BUZZ_PUSH_ENABLED_PROFILES, value: {{ .Values.enabledProfiles | quote }} } - - { name: BUZZ_PUSH_APP_ATTEST_APP_ID, value: {{ .Values.appAttestAppId | quote }} } - { name: BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH, value: /run/buzz/app-attest/root.pem } - - { name: BUZZ_PUSH_APNS_KEY_PATH, value: /run/buzz/apns/provider.p8 } - {{- range $name := list "DATABASE_URL" "BUZZ_PUSH_APNS_KEY_ID" "BUZZ_PUSH_APNS_TEAM_ID" "BUZZ_PUSH_APNS_TOPIC" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }} + - { name: BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID, value: {{ .Values.profiles.dogfood.appAttestAppId | quote }} } + - { name: BUZZ_PUSH_DOGFOOD_APNS_TOPIC, value: {{ .Values.profiles.dogfood.apnsTopic | quote }} } + - { name: BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT, value: {{ .Values.profiles.dogfood.apnsEnvironment | quote }} } + - { name: BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH, value: /run/buzz/apns-dogfood/identity.pem } + - { name: BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID, value: {{ .Values.profiles.appStore.appAttestAppId | quote }} } + - { name: BUZZ_PUSH_APP_STORE_APNS_TOPIC, value: {{ .Values.profiles.appStore.apnsTopic | quote }} } + - { name: BUZZ_PUSH_APP_STORE_APNS_ENVIRONMENT, value: {{ .Values.profiles.appStore.apnsEnvironment | quote }} } + {{- with .Values.profiles.appStore.apnsCert }} + - { name: BUZZ_PUSH_APP_STORE_APNS_CERT_PATH, value: /run/buzz/apns-app-store/identity.pem } + {{- end }} + {{- range $name := list "DATABASE_URL" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }} - name: {{ $name }} valueFrom: { secretKeyRef: { name: {{ $.Values.existingSecret }}, key: {{ $name }} } } {{- end }} volumeMounts: - { name: app-attest-root, mountPath: /run/buzz/app-attest, readOnly: true } - - { name: apns-key, mountPath: /run/buzz/apns, readOnly: true } + - { name: apns-dogfood, mountPath: /run/buzz/apns-dogfood, readOnly: true } + {{- with .Values.profiles.appStore.apnsCert }} + - { name: apns-app-store, mountPath: /run/buzz/apns-app-store, readOnly: true } + {{- end }} livenessProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 3 } readinessProbe: { httpGet: { path: /_readiness, port: health }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 3 } startupProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 2, failureThreshold: 60 } @@ -49,8 +60,12 @@ spec: volumes: - name: app-attest-root secret: { secretName: {{ .Values.appAttestRoot.secretName }}, items: [{ key: {{ .Values.appAttestRoot.secretKey }}, path: root.pem }] } - - name: apns-key - secret: { secretName: {{ .Values.apnsKey.secretName }}, items: [{ key: {{ .Values.apnsKey.secretKey }}, path: provider.p8 }] } + - name: apns-dogfood + secret: { secretName: {{ .Values.profiles.dogfood.apnsCert.secretName }}, defaultMode: 0400, items: [{ key: {{ .Values.profiles.dogfood.apnsCert.secretKey }}, path: identity.pem }] } + {{- with .Values.profiles.appStore.apnsCert }} + - name: apns-app-store + secret: { secretName: {{ .secretName }}, defaultMode: 0400, items: [{ key: {{ .secretKey }}, path: identity.pem }] } + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} diff --git a/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml b/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml index 20b9894280a..7a718bda718 100644 --- a/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml +++ b/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml @@ -21,9 +21,9 @@ spec: annotations: summary: Push gateway APNs configuration faults description: >- - APNs is returning configuration faults (bad/expired provider token - or topic). Deliveries are failing without invalidating endpoints. - See runbook: check the APNs .p8 key, key id, team id, and topic. + APNs is returning certificate or topic configuration faults. + Deliveries are failing without invalidating endpoints. See the + runbook and check the APNs certificate identity and topic. # Authority store unavailable at admission = durable dependency is down. - alert: PushGatewayAdmissionUnavailable expr: | diff --git a/deploy/charts/buzz-push-gateway/tests/release-contract.sh b/deploy/charts/buzz-push-gateway/tests/release-contract.sh index 7ae85ce34e3..993c4c05369 100755 --- a/deploy/charts/buzz-push-gateway/tests/release-contract.sh +++ b/deploy/charts/buzz-push-gateway/tests/release-contract.sh @@ -1,30 +1,29 @@ #!/usr/bin/env bash set -euo pipefail -python3 - <<'PY' -from pathlib import Path -import yaml - -auto_path = Path('.github/workflows/auto-tag-on-release-pr-merge.yml') -publish_path = Path('.github/workflows/push-gateway-helm-chart.yml') -auto_text = auto_path.read_text() -publish_text = publish_path.read_text() -# Parse first, then pin the cross-workflow strings whose agreement makes this a -# reachable lane rather than an orphan publisher. -yaml.safe_load(auto_text) -yaml.safe_load(publish_text) -for needle in ( - 'push-chart-release/*)', - 'VERSION="${BRANCH#push-chart-release/}"', - 'TAG_PREFIX="push-chart-v"', - 'DISPATCH="push-gateway-helm-chart"', - 'push-gateway-helm-chart) WORKFLOW="push-gateway-helm-chart.yml"', -): - assert needle in auto_text, f'missing auto-tag gateway chart contract: {needle}' -for needle in ( - 'tags: ["push-chart-v[0-9]*"]', - 'version="${INPUT_VERSION:-${REF_NAME#push-chart-v}}"', - 'refs/tags/push-chart-v${version}^{commit}', - 'deploy/charts/buzz-push-gateway', -): - assert needle in publish_text, f'missing gateway chart publisher contract: {needle}' -PY +env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml <<'RUBY' +auto_text = File.read('.github/workflows/auto-tag-on-release-pr-merge.yml') +publish_text = File.read('.github/workflows/push-gateway-helm-chart.yml') +# Parse first, then pin the tag producer and consumer strings whose agreement +# makes this a reachable lane rather than an orphan publisher. +YAML.load(auto_text) +YAML.load(publish_text) +[ + 'push-chart-release/*)', + 'VERSION="${BRANCH#push-chart-release/}"', + 'TAG_PREFIX="push-chart-v"', + '- name: Create and push tag', + 'TAG: ${{ steps.release.outputs.tag }}', + 'refs/tags/$TAG', + '-f sha="$TARGET_SHA"', +].each do |needle| + raise "missing auto-tag gateway chart contract: #{needle}" unless auto_text.include?(needle) +end +[ + 'tags: ["push-chart-v[0-9]*"]', + 'version="${INPUT_VERSION:-${REF_NAME#push-chart-v}}"', + 'refs/tags/push-chart-v${version}^{commit}', + 'deploy/charts/buzz-push-gateway', +].each do |needle| + raise "missing gateway chart publisher contract: #{needle}" unless publish_text.include?(needle) +end +RUBY diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh index 137f8d0add7..43a8b360cef 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -10,7 +10,7 @@ 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 'appAttestAppId=REALTEAM.xyz.buzz' + --set 'profiles.dogfood.appAttestAppId=REALTEAM.xyz.block.buzz.dogfood.mobile' --set 'httpRoute.parentRefs[0].name=production-gateway' --set 'httpRoute.parentRefs[0].namespace=gateway-system' --set 'networkPolicy.postgresEgressCidrs[0]=10.42.0.0/16' @@ -18,60 +18,88 @@ production_args=( helm lint deploy/charts/buzz-push-gateway "${production_args[@]}" >/dev/null helm template push deploy/charts/buzz-push-gateway "${production_args[@]}" >"$production_out" -python3 - "$out" "$production_out" <<'PY' -import sys,yaml -xs=list(yaml.safe_load_all(open(sys.argv[1]))) -svc=next(x for x in xs if x and x.get('kind')=='Service') -assert [p['targetPort'] for p in svc['spec']['ports']]==['public'] -d=next(x for x in xs if x and x.get('kind')=='Deployment') -j=next(x for x in xs if x and x.get('kind')=='Job') -runtime={'app.kubernetes.io/name':'buzz-push-gateway','app.kubernetes.io/instance':'push','app.kubernetes.io/component':'runtime'} -migration={**runtime,'app.kubernetes.io/component':'migration'} -assert svc['spec']['selector']==runtime -assert d['spec']['selector']['matchLabels']==runtime -assert d['spec']['template']['metadata']['labels']==runtime -assert j['spec']['template']['metadata']['labels']==migration -assert svc['spec']['selector'] != j['spec']['template']['metadata']['labels'] -jenv={e['name']:e for e in j['spec']['template']['spec']['containers'][0]['env']} -assert jenv['BUZZ_PUSH_RUNTIME_DATABASE_ROLE']['value']=='buzz_push_gateway_runtime' -assert 'valueFrom' in jenv['DATABASE_URL'] -assert j['spec']['template']['spec']['containers'][0]['args']==['--migrate-only'] -assert j['metadata']['annotations']=={ - 'helm.sh/hook':'pre-install,pre-upgrade', - 'helm.sh/hook-weight':'-5', - 'helm.sh/hook-delete-policy':'before-hook-creation,hook-succeeded', +env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml -rset \ + - "$out" "$production_out" <<'RUBY' +def assert!(condition, detail = "assertion failed") + raise detail unless condition +end + +xs = YAML.load_stream(File.read(ARGV[0])).compact +svc = xs.find { |x| x["kind"] == "Service" } +assert!(svc.dig("spec", "ports").map { |port| port["targetPort"] } == ["public"]) +d = xs.find { |x| x["kind"] == "Deployment" } +j = xs.find { |x| x["kind"] == "Job" } +runtime = { + "app.kubernetes.io/name" => "buzz-push-gateway", + "app.kubernetes.io/instance" => "push", + "app.kubernetes.io/component" => "runtime", } -env={e['name'] for e in d['spec']['template']['spec']['containers'][0]['env']} -required={'DATABASE_URL','BUZZ_PUSH_APNS_KEY_ID','BUZZ_PUSH_APNS_TEAM_ID','BUZZ_PUSH_APNS_TOPIC','BUZZ_PUSH_GRANT_KEYS','BUZZ_PUSH_TOKEN_KEYS','BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS'} -assert required <= env -assert d['spec']['replicas'] >= 2 -assert not any(x and x.get('kind')=='HTTPRoute' for x in xs) +migration = runtime.merge("app.kubernetes.io/component" => "migration") +assert!(svc.dig("spec", "selector") == runtime) +assert!(d.dig("spec", "selector", "matchLabels") == runtime) +assert!(d.dig("spec", "template", "metadata", "labels") == runtime) +assert!(j.dig("spec", "template", "metadata", "labels") == migration) +assert!(svc.dig("spec", "selector") != j.dig("spec", "template", "metadata", "labels")) +jenv = j.dig("spec", "template", "spec", "containers", 0, "env").to_h { |entry| [entry["name"], entry] } +assert!(jenv.dig("BUZZ_PUSH_RUNTIME_DATABASE_ROLE", "value") == "buzz_push_gateway_runtime") +assert!(jenv.fetch("DATABASE_URL").key?("valueFrom")) +assert!(j.dig("spec", "template", "spec", "containers", 0, "args") == ["--migrate-only"]) +assert!(j.dig("metadata", "annotations") == { + "helm.sh/hook" => "pre-install,pre-upgrade", + "helm.sh/hook-weight" => "-5", + "helm.sh/hook-delete-policy" => "before-hook-creation,hook-succeeded", +}) +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 + BUZZ_PUSH_APP_STORE_APNS_TOPIC BUZZ_PUSH_APP_STORE_APP_ATTEST_APP_ID + BUZZ_PUSH_GRANT_KEYS BUZZ_PUSH_TOKEN_KEYS BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS +]) +assert!(required.subset?(env_names)) +assert!(!env_names.include?("BUZZ_PUSH_APP_STORE_APNS_CERT_PATH")) +apns_volume = d.dig("spec", "template", "spec", "volumes").find { |volume| volume["name"] == "apns-dogfood" } +assert!(apns_volume.dig("secret", "defaultMode") == 0o400, apns_volume.inspect) +assert!(d.dig("spec", "replicas") >= 2) +assert!(!xs.any? { |x| x["kind"] == "HTTPRoute" }) # Observability is opt-in: default render exposes no scrape CRDs and 8081 stays # free of pod ingress (only 8080 is reachable). -assert not any(x and x.get('kind') in ('PodMonitor','PrometheusRule') for x in xs) -nps=[x for x in xs if x and x.get('kind')=='NetworkPolicy'] -np=next(x for x in nps if x['metadata']['name']=='push-buzz-push-gateway') -migration_np=next(x for x in nps if x['metadata']['name']=='push-buzz-push-gateway-migration') -assert np['spec']['podSelector']['matchLabels']==runtime -assert migration_np['spec']['podSelector']['matchLabels']==migration -assert migration_np['metadata']['annotations']=={ - 'helm.sh/hook':'pre-install,pre-upgrade', - 'helm.sh/hook-weight':'-10', - 'helm.sh/hook-delete-policy':'before-hook-creation', -} -assert int(migration_np['metadata']['annotations']['helm.sh/hook-weight']) < int(j['metadata']['annotations']['helm.sh/hook-weight']) -assert migration_np['spec']['ingress']==[] -assert migration_np['spec']['policyTypes']==['Ingress','Egress'] -migration_ports={p['port'] for rule in migration_np['spec']['egress'] for p in rule.get('ports',[])} -assert migration_ports=={53,5432}, migration_ports -assert all(p['port'] != 443 for rule in migration_np['spec']['egress'] for p in rule.get('ports',[])) -ingress_ports={p['port'] for rule in np['spec']['ingress'] for p in rule.get('ports',[])} -assert ingress_ports=={8080}, ingress_ports -production=list(yaml.safe_load_all(open(sys.argv[2]))) -route=next(x for x in production if x and x.get('kind')=='HTTPRoute') -assert route['spec']['parentRefs'] -assert 'push.buzz.xyz' in route['spec']['hostnames'] -PY +assert!(!xs.any? { |x| %w[PodMonitor PrometheusRule].include?(x["kind"]) }) +nps = xs.select { |x| x["kind"] == "NetworkPolicy" } +np = nps.find { |x| x.dig("metadata", "name") == "push-buzz-push-gateway" } +migration_np = nps.find { |x| x.dig("metadata", "name") == "push-buzz-push-gateway-migration" } +assert!(np.dig("spec", "podSelector", "matchLabels") == runtime) +assert!(migration_np.dig("spec", "podSelector", "matchLabels") == migration) +assert!(migration_np.dig("metadata", "annotations") == { + "helm.sh/hook" => "pre-install,pre-upgrade", + "helm.sh/hook-weight" => "-10", + "helm.sh/hook-delete-policy" => "before-hook-creation", +}) +assert!(migration_np.dig("metadata", "annotations", "helm.sh/hook-weight").to_i < j.dig("metadata", "annotations", "helm.sh/hook-weight").to_i) +assert!(migration_np.dig("spec", "ingress") == []) +assert!(migration_np.dig("spec", "policyTypes") == %w[Ingress Egress]) +migration_ports = migration_np.dig("spec", "egress") + .flat_map { |rule| rule.fetch("ports", []) }.map { |port| port["port"] }.to_set +assert!(migration_ports == Set[53, 5432], migration_ports.inspect) +assert!(!migration_np.dig("spec", "egress").flat_map { |rule| rule.fetch("ports", []) }.any? { |port| port["port"] == 443 }) +ingress_ports = np.dig("spec", "ingress") + .flat_map { |rule| rule.fetch("ports", []) }.map { |port| port["port"] }.to_set +assert!(ingress_ports == Set[8080], ingress_ports.inspect) +production = YAML.load_stream(File.read(ARGV[1])).compact +route = production.find { |x| x["kind"] == "HTTPRoute" } +assert!(!route.dig("spec", "parentRefs").empty?) +assert!(route.dig("spec", "hostnames").include?("push.buzz.xyz")) +RUBY + +# Legacy token-auth values must fail rather than silently selecting the default +# certificate Secret. +if helm template push deploy/charts/buzz-push-gateway \ + --set apnsKey.secretName=legacy-apns-secret \ + --set apnsKey.secretKey=legacy-provider.p8 >/dev/null 2>&1; then + echo 'expected legacy apnsKey values to fail schema validation' >&2 + exit 1 +fi # Enabling a route without a Gateway attachment must fail schema validation. if helm template push deploy/charts/buzz-push-gateway --set httpRoute.enabled=true >/dev/null 2>&1; then @@ -97,20 +125,28 @@ helm template push deploy/charts/buzz-push-gateway \ --set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \ >"$monitoring_out" -python3 - "$monitoring_out" <<'PY' -import sys,yaml -xs=list(yaml.safe_load_all(open(sys.argv[1]))) -pm=next(x for x in xs if x and x.get('kind')=='PodMonitor') -ep=pm['spec']['podMetricsEndpoints'][0] -assert ep['port']=='health' and ep['path']=='/metrics', ep -assert next(x for x in xs if x and x.get('kind')=='PrometheusRule')['spec']['groups'] -np=next(x for x in xs if x and x.get('kind')=='NetworkPolicy' and x['metadata']['name']=='push-buzz-push-gateway') -mon=[r for r in np['spec']['ingress'] if {p['port'] for p in r.get('ports',[])}=={8081}] -assert len(mon)==1, 'exactly one scoped 8081 ingress rule' -frm=mon[0]['from'][0] +env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml -rset \ + - "$monitoring_out" <<'RUBY' +def assert!(condition, detail = "assertion failed") + raise detail unless condition +end + +xs = YAML.load_stream(File.read(ARGV[0])).compact +pm = xs.find { |x| x["kind"] == "PodMonitor" } +endpoint = pm.dig("spec", "podMetricsEndpoints", 0) +assert!(endpoint["port"] == "health" && endpoint["path"] == "/metrics", endpoint.inspect) +assert!(!xs.find { |x| x["kind"] == "PrometheusRule" }.dig("spec", "groups").empty?) +np = xs.find do |x| + x["kind"] == "NetworkPolicy" && x.dig("metadata", "name") == "push-buzz-push-gateway" +end +monitoring = np.dig("spec", "ingress").select do |rule| + rule.fetch("ports", []).map { |port| port["port"] }.to_set == Set[8081] +end +assert!(monitoring.length == 1, "exactly one scoped 8081 ingress rule") +from = monitoring[0].fetch("from")[0] # 8081 ingress must be scoped by both selectors, never empty/blanket. -assert frm['namespaceSelector']['matchLabels'] and frm['podSelector']['matchLabels'], frm -PY +assert!(!from.dig("namespaceSelector", "matchLabels").empty? && !from.dig("podSelector", "matchLabels").empty?, from.inspect) +RUBY # Negative: monitoring enabled with default empty selectors must fail (would # otherwise render a blanket 8081 rule matching all namespaces/pods). diff --git a/deploy/charts/buzz-push-gateway/values-production.yaml b/deploy/charts/buzz-push-gateway/values-production.yaml index 7a0569616fa..8017f6bacdb 100644 --- a/deploy/charts/buzz-push-gateway/values-production.yaml +++ b/deploy/charts/buzz-push-gateway/values-production.yaml @@ -3,7 +3,9 @@ image: tag: "" digest: "" -appAttestAppId: "" +profiles: + dogfood: + appAttestAppId: "" httpRoute: enabled: true parentRefs: [] diff --git a/deploy/charts/buzz-push-gateway/values.schema.json b/deploy/charts/buzz-push-gateway/values.schema.json index 631a04aea5a..bef5d17a338 100644 --- a/deploy/charts/buzz-push-gateway/values.schema.json +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -19,10 +19,23 @@ "minimum": 1, "maximum": 31536000 }, - "appAttestAppId": { - "type": "string", - "minLength": 1 + "profiles": { + "type": "object", + "additionalProperties": false, + "required": [ + "dogfood", + "appStore" + ], + "properties": { + "dogfood": { + "$ref": "#/$defs/enabledProfile" + }, + "appStore": { + "$ref": "#/$defs/dormantProfile" + } + } }, + "apnsKey": false, "httpRoute": { "type": "object", "required": [ @@ -232,12 +245,75 @@ } } }, + "$defs": { + "profileBase": { + "type": "object", + "additionalProperties": false, + "required": [ + "appAttestAppId", + "apnsTopic", + "apnsEnvironment" + ], + "properties": { + "appAttestAppId": { + "type": "string", + "minLength": 1 + }, + "apnsTopic": { + "type": "string", + "minLength": 1 + }, + "apnsEnvironment": { + "enum": [ + "production", + "sandbox" + ] + }, + "apnsCert": { + "$ref": "#/$defs/apnsCert" + } + } + }, + "enabledProfile": { + "allOf": [ + { + "$ref": "#/$defs/profileBase" + }, + { + "required": [ + "apnsCert" + ] + } + ] + }, + "dormantProfile": { + "$ref": "#/$defs/profileBase" + }, + "apnsCert": { + "type": "object", + "additionalProperties": false, + "required": [ + "secretName", + "secretKey" + ], + "properties": { + "secretName": { + "type": "string", + "minLength": 1 + }, + "secretKey": { + "type": "string", + "minLength": 1 + } + } + } + }, "required": [ "replicaCount", "existingSecret", "publicDeliveryUrl", "maxGrantLifetimeSeconds", - "appAttestAppId", + "profiles", "httpRoute", "image", "migration" diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index ec46d9dbdd8..5e5c770623b 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -20,16 +20,25 @@ migration: limits: {cpu: 250m, memory: 128Mi} publicDeliveryUrl: https://push.buzz.xyz/v1/deliveries/apns maxGrantLifetimeSeconds: 2592000 -enabledProfiles: buzz-ios-production -# Example App Attest identifier. Production MUST override this with the exact -# Apple TEAMID.bundle-id value (see values-production.yaml). -appAttestAppId: TEAMID.xyz.buzz +enabledProfiles: buzz-ios-dogfood +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 + appStore: + # Registered server-side now, but dormant and uncredentialed until its + # profile is explicitly added to enabledProfiles in a later rollout. + appAttestAppId: TEAMID.xyz.block.buzz.mobile + apnsTopic: xyz.block.buzz.mobile + apnsEnvironment: production appAttestRoot: secretName: buzz-push-gateway secretKey: app-attest-root.pem -apnsKey: - secretName: buzz-push-gateway - secretKey: apns-provider.p8 service: port: 8080 httpRoute: diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 08fa6ca34d9..df6428e7cdc 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -264,7 +264,17 @@ This section registers the public last-hop profile served at `https://push.buzz. ### Registered values and lease mapping -The registered `app_profile` values are `buzz-ios-production` (Apple production APNs environment) and `buzz-ios-sandbox` (Apple sandbox APNs environment). A gateway deployment MUST enable only profiles for which its App Attest application identifier, APNs topic, credentials, and APNs environment are configured consistently. The APNs token registered with the gateway is called the **installation endpoint** and never leaves gateway custody after enrollment. +The registered `app_profile` values are `buzz-ios-dogfood` and +`buzz-ios-app-store`. They identify closed Buzz application identities, not APNs +transport environments. The canonical gateway owns the mapping from each +profile to one exact App Attest application identifier, APNs topic, +certificate-backed connection pool, and APNs environment. A client profile +selector chooses only a candidate entry; enrollment succeeds only when App +Attest cryptographically verifies the configured application identifier. A +gateway deployment MUST enable only profiles whose full mapping is configured +consistently, and MUST NOT accept an APNs topic from a client. The APNs token +registered with the gateway is called the **installation endpoint** and never +leaves gateway custody after enrollment. The opaque string returned as `endpoint_grant` by `POST /v1/delegations` is the **delivery capability**. For this profile, the active lease plaintext's `endpoint` member MUST contain that `endpoint_grant`, not the raw APNs token. `transport` MUST be `apns`, and `app_profile` MUST equal the profile sealed into the grant. Base-protocol endpoint uniqueness, rotation, hashing, and coalescing operate on this opaque lease `endpoint` within an origin. A capability is scoped to one installation, relay signing pubkey, endpoint epoch, generation, and expiry; grants independently issued to different relays are intentionally distinct. The gateway separately enforces global installation-endpoint uniqueness using `(app_profile, SHA-256(token))`. A public-profile relay MUST treat `endpoint` as opaque and MUST NOT parse or transform it. @@ -305,7 +315,7 @@ The challenge is single-use. Invalid input is `400 invalid_request`; storage/ran Request members, in any request order: ```json -{"v":1,"challenge_id":"","challenge":"","key_id":"","attestation":"","app_profile":"buzz-ios-production","endpoint":"","endpoint_epoch":1,"expires_at":} +{"v":1,"challenge_id":"","challenge":"","key_id":"","attestation":"","app_profile":"buzz-ios-dogfood","endpoint":"","endpoint_epoch":1,"expires_at":} ``` `expires_at` MUST satisfy `now < expires_at <= now + configured_max_installation_lifetime`; the selected profile MUST be enabled. The exact transcript is domain `buzz.push.enroll.v1` followed by this ordered object: @@ -407,7 +417,9 @@ Responses: - `404 {"error":"invalid_grant"}` — capability, signer, authority, replay, expiry, or quota rejection. - `503 {"error":"temporarily_unavailable"}` — durable authority/custody/disposition failure. -The gateway performs one APNs request, except that an APNs expired-provider-token response permits one credential refresh and one retry. The application body is always the exact constant registered in the APNs transport profile above; no request or grant field enters it. +The gateway performs one APNs request per admitted delivery attempt. The +application body is always the exact constant registered in the APNs transport +profile above; no request or grant field enters it. ## Implementation Notes (Buzz, non-normative) @@ -439,4 +451,4 @@ Zombie leases (e.g. `#h` after leaving a channel) are neutralized by match-time - NIP-11 `supported_extensions`: contains `"nip-pl"` pre-numbering; descriptor object `push` as specified in Executor Discovery - Classes: `silent`, `default`, `time_sensitive`, `urgent` - `h_grammar` values: `"uuid-v4-lowercase"` (initial entry; origins may register additional grammars with this NIP) -- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profiles `buzz-ios-production`, `buzz-ios-sandbox`; wire version `1` +- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profiles `buzz-ios-dogfood`, `buzz-ios-app-store`; wire version `1` diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 63c63355a11..8a2593c7773 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -16,21 +16,31 @@ | `BUZZ_PUSH_PUBLIC_DELIVERY_URL` | Exact externally signed URL, normally `https://push.buzz.xyz/v1/deliveries/apns`. | | `BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS` | Maximum delegation capability lifetime (`1..=31536000`). | | `BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS` | Maximum encrypted-token installation lifetime (default 90 days, max one year). Clients must renew before expiry. | -| `BUZZ_PUSH_ENABLED_PROFILES` | Comma-separated `buzz-ios-production` and/or `buzz-ios-sandbox`. | -| `BUZZ_PUSH_APP_ATTEST_APP_ID` | Exact Apple App Attest application identifier (`TEAMID.bundle-id`). | +| `BUZZ_PUSH_ENABLED_PROFILES` | Comma-separated closed application profiles: `buzz-ios-dogfood` and/or `buzz-ios-app-store`. Dogfood is the only enabled MVP profile. | | `BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. | -| `BUZZ_PUSH_APNS_KEY_PATH` | Read-only mounted Apple APNs `.p8` provider key. | -| `BUZZ_PUSH_APNS_KEY_ID` | APNs provider key id. | -| `BUZZ_PUSH_APNS_TEAM_ID` | Apple developer team id. | -| `BUZZ_PUSH_APNS_TOPIC` | Buzz iOS bundle id. | +| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APP_ATTEST_APP_ID` | Exact server-owned Apple App Attest application identifier (`TEAMID.bundle-id`) for each closed profile. | +| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_TOPIC` | Server-owned APNs topic for each profile. Never accepted from a client. | +| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_ENVIRONMENT` | `production` or `sandbox`, selected per profile by deployment configuration. | +| `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_CERT_PATH` | Read-only certificate/private-key PEM for an enabled profile. A dormant profile may omit it. | | `BUZZ_PUSH_GRANT_KEYS` | Capability AEAD keyring, `id:base64-32-bytes[,predecessor...]`; current key first. | | `BUZZ_PUSH_TOKEN_KEYS` | Independent token-custody AEAD keyring in the same format. Never reuse grant keys. | +The single canonical `push.buzz.xyz` deployment owns a closed profile registry +for both dogfood (`xyz.block.buzz.dogfood.mobile`) and App Store +(`xyz.block.buzz.mobile`) application identities. Enrollment's profile selector +only chooses a candidate entry: the corresponding App Attest verifier must +cryptographically validate that entry's configured application ID before the +profile is stored with the installation. Assertions and delivery subsequently +select App Attest policy, APNs topic, certificate-backed connection pool, and +environment from that stored profile. No client request or relay grant can +supply or override an APNs topic. The MVP enables and credentials dogfood only; +the App Store entry remains registered but dormant until a later rollout. + 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. ## Secret and key rotation rules -Mount the App Attest root read-only and startup will reject any byte mismatch. The sole accepted artifact is Apple’s **Apple App Attestation Root CA** from `https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem`: certificate SHA-256 fingerprint `1C:B9:82:3B:A2:8B:A6:AD:2D:33:A0:06:94:1D:E2:AE:4F:51:3E:F1:D4:E8:31:B9:F7:E0:FA:7B:62:42:C9:32`; exact PEM-file SHA-256 `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. Treat an Apple root rotation as a reviewed code/config rollout, not an unpinned mount replacement. Mount the APNs key and both AEAD keyrings from a secret manager; never place values in an image, manifest, log, or metrics label. Keep the current AEAD key first and retain decrypt-only predecessors until every capability/token encrypted under them has expired or been re-encrypted. Grant and token key ids and bytes must be distinct. Rotation is an operator rollout: add the new current key while retaining predecessors, deploy, wait through the retention window, then remove the old key. +Mount the App Attest root read-only and startup will reject any byte mismatch. The sole accepted artifact is Apple’s **Apple App Attestation Root CA** from `https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem`: certificate SHA-256 fingerprint `1C:B9:82:3B:A2:8B:A6:AD:2D:33:A0:06:94:1D:E2:AE:4F:51:3E:F1:D4:E8:31:B9:F7:E0:FA:7B:62:42:C9:32`; exact PEM-file SHA-256 `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. Treat an Apple root rotation as a reviewed code/config rollout, not an unpinned mount replacement. Mount the APNs certificate identity and both AEAD keyrings from a secret manager; never place values in an image, manifest, log, or metrics label. Keep the current AEAD key first and retain decrypt-only predecessors until every capability/token encrypted under them has expired or been re-encrypted. Grant and token key ids and bytes must be distinct. Rotation is an operator rollout: add the new current key while retaining predecessors, deploy, wait through the retention window, then remove the old key. The gateway stores APNs tokens encrypted in PostgreSQL. Database backups therefore contain ciphertext plus authority metadata and must receive the same access controls and retention treatment as the service secrets. @@ -44,13 +54,12 @@ The service reaps expired challenges and replay rows, idle quota rows, expired/r ## Metrics and alerting -The gateway serves Prometheus metrics at `GET /metrics` on the **private health listener** (`BUZZ_PUSH_HEALTH_ADDR`, default `0.0.0.0:8081`) — the same port as the probes, never on the public `8080`. All series are sanitized and bounded-cardinality: label values are drawn only from closed sets (the six APNs outcome classes, the fixed admission results, the static error codes already returned to callers, and the readiness causes). No endpoint, device token, relay pubkey, request id, or any request-scoped identifier is ever used as a label. +The gateway serves Prometheus metrics at `GET /metrics` on the **private health listener** (`BUZZ_PUSH_HEALTH_ADDR`, default `0.0.0.0:8081`) — the same port as the probes, never on the public `8080`. All series are sanitized and bounded-cardinality: label values are drawn only from closed sets (the five APNs outcome classes, the fixed admission results, the static error codes already returned to callers, and the readiness causes). No endpoint, device token, relay pubkey, request id, or any request-scoped identifier is ever used as a label. | Metric | Type | Labels | Meaning | |---|---|---|---| -| `push_gateway_apns_deliveries_total` | counter | `outcome` = `accepted` \| `invalid_endpoint` \| `retry` \| `refresh_credential` \| `configuration_fault` \| `permanent_request_fault` | Terminal APNs send outcomes. | +| `push_gateway_apns_deliveries_total` | counter | `outcome` = `accepted` \| `invalid_endpoint` \| `retry` \| `configuration_fault` \| `permanent_request_fault` | Terminal APNs send outcomes. | | `push_gateway_apns_delivery_seconds` | histogram | — | APNs send round-trip latency (seconds). | -| `push_gateway_apns_credential_refreshes_total` | counter | — | Provider JWT refreshed after APNs reported expiry. | | `push_gateway_admissions_total` | counter | `result` = `admitted` \| `rejected` \| `unavailable` | Outcome at the `authorize_delivery` replay/quota fence. | | `push_gateway_delivery_errors_total` | counter | `class` (static) | Selected delivery-handler exit classes only (see note). | | `push_gateway_reaper_failures_total` | counter | — | Retention reaper sweep failures. | @@ -64,7 +73,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 | APNs provider token/topic is unhealthy. Check the `.p8` key, `BUZZ_PUSH_APNS_KEY_ID`, `..._TEAM_ID`, and `..._TOPIC`. No endpoints are being invalidated, but nothing is delivering. | +| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | The enabled profile's APNs certificate/topic/environment is unhealthy. Check the matching `BUZZ_PUSH_{DOGFOOD,APP_STORE}_APNS_*` configuration. No endpoints are being invalidated. | | `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. | @@ -72,23 +81,101 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe ## Relay configuration -Relays default `BUZZ_PUSH_GATEWAY_DELIVERY_URL` to the exact public delivery URL -`https://push.buzz.xyz/v1/deliveries/apns`. Operators can override it with -another exact HTTPS `/v1/deliveries/apns` URL, or explicitly disable NIP-PL push -by setting the variable to an empty string. When enabled, the relay advertises -its host-scoped NIP-PL descriptor in NIP-11 and starts the matcher and delivery -worker. Relays retain lease matching, authorization, coalescing, durable +Relay push is an explicit deployment opt-in through `BUZZ_PUSH_ENABLED=true`; +the established strict boolean parser rejects unknown values and the default is +false. When enabled, an absent `BUZZ_PUSH_GATEWAY_DELIVERY_URL` selects the exact +canonical URL `https://push.buzz.xyz/v1/deliveries/apns`; operators can provide +another exact HTTPS `/v1/deliveries/apns` URL as an advanced override. An +explicitly empty URL while enabled is a startup error. Only an enabled relay +advertises its host-scoped NIP-PL descriptor, accepts leases, and starts the +matcher and delivery worker. Relays retain lease matching, authorization, durable jobs/retries, and generation checks; they receive only opaque capabilities and never APNs tokens or provider credentials. +An enabled relay exports the following bounded-cardinality series on its +existing Prometheus endpoint. None carries a community, account, relay key, +installation, event, or request identifier as a label. + +| Metric | Type | Labels | Meaning | +|---|---|---|---| +| `buzz_push_enabled` | gauge | — | `1` only when the deployment opt-in is active. | +| `buzz_push_match_jobs_total` | counter | `result` = `matched` \| `unmatched` \| `error` \| `context_error` | Accepted message events evaluated by the matcher. | +| `buzz_push_match_queue_seconds` | histogram | — | Relay receipt to matcher evaluation latency. | +| `buzz_push_wakes_total` | counter | `result` = `enqueued` \| `duplicate` \| `inactive_lease` | Durable wake-enqueue outcomes. | +| `buzz_push_wake_enqueue_errors_total` | counter | — | Set-wise outbox transactions that failed. | +| `buzz_push_wake_queue_seconds` | histogram | — | First-attempt outbox enqueue-to-worker latency. | +| `buzz_push_gateway_requests_total` | counter | — | Relay requests that reached the gateway transport seam. | +| `buzz_push_gateway_request_seconds` | histogram | — | Relay-observed gateway request latency. | +| `buzz_push_deliveries_total` | counter | `outcome` (static closed set) | Accepted, retried, suppressed, exhausted, invalid, or failed relay delivery outcomes. | + ## Relay integration status The operational relay integration is complete: per-origin event matching with read-authorization checks, durable enqueue, send-time revalidation, and NIP-98 -delivery run whenever the gateway URL is enabled. End-to-end use still requires +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 + +The MVP is ready to enable only when all of these are true: the internal iOS +artifact was built with `mobile/ios/Flutter/PushEnabled.xcconfig`; the canonical +gateway has the dogfood profile enabled 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`. +The App Store profile remains configured but dormant, and ordinary/App Store +iOS builds continue to omit the push capability and notification extension. + +Local physical-device development may instead use the normal +`xyz.block.buzz.mobile` development identity with the push overlay and sandbox +entitlements. Its local gateway must enable only the closed App Store profile, +configured with that profile's server-owned App Attest application ID, APNs +topic, sandbox certificate, and sandbox environment. This is a development +integration proof, not dogfood release validation, and does not authorize +enabling the App Store profile on the canonical production deployment. + +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 canonical gateway and a push-enabled +internal relay before widening the internal evaluation. + +Before that first push-enabled candidate, the private dogfood builder must +include `PushEnabled.xcconfig` from its generated `AppOverrides.xcconfig`. Its +manual signing and export configuration must also 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. The App Store builder must continue omitting the +overlay and extension profile until that rollout is 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. + +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 +ratio of accepted or replay-terminal relay outcomes to newly enqueued wakes, +gateway APNs accepted/retry/invalid/configuration outcomes, retry exhaustion, +and NSE resolution fallback. APNs acceptance cannot prove device presentation: +record a small manual physical-device sample with event-created, banner-visible, +and notification-tap timestamps, and verify that the visible title/body came +from fetched, signature-verified relay content and that the tap opened the exact +triggering message. Keep fallback-to-channel and placeholder/failure cases as +explicit counts in the manual sample until privacy-preserving client telemetry +is designed. + +Rollback does not require deleting credentials or mutating existing leases. +Set `BUZZ_PUSH_ENABLED=false` on the enabled relays to stop advertisement, lease +acceptance, matching, workers, and new gateway traffic; disable the dogfood +gateway profile if the gateway itself is unhealthy; and ship the next internal +build without the push overlay if client behavior must be removed. Existing +leases and gateway authorities then expire naturally. Do not enable the App +Store build capability or profile as part of this internal evaluation. + ## Helm production inputs The chart defaults to the `main` image tag because `.github/workflows/docker.yml` publishes it from the push-gateway lane. For a production rollout, open that workflow run's **Publish public push gateway image** job summary and copy its `sha256:...` digest. Verify the published subject and provenance before injecting it: @@ -99,11 +186,11 @@ gh attestation verify \ --owner block ``` -Only after that command succeeds, set the exact digest as `image.digest`; the chart then renders `ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag. `values-production.yaml` is an intentionally invalid production-input contract: deployment CI must inject this verified `image.digest`, the provisioned Apple application identifier, an environment-owned Gateway parent reference, and the actual PostgreSQL network. Schema validation rejects the artifact when any remains empty; the render guard proves both rejection and a fully injected render. +Only after that command succeeds, set the exact digest as `image.digest`; the chart then renders `ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag. `values-production.yaml` is an intentionally invalid production-input contract: deployment CI must inject this verified `image.digest`, the provisioned dogfood Apple application identifier, an environment-owned Gateway parent reference, and the actual PostgreSQL network. Schema validation rejects the artifact when any remains empty; the render guard proves both rejection and a fully injected render. Network policy keeps APNs HTTPS and PostgreSQL egress in separate CIDR lists. APNs currently requires broad TCP/443 reachability; `networkPolicy.postgresEgressCidrs` must be narrowed to the production database network, and the DNS namespace/pod selectors must match the cluster DNS deployment. The sample private CIDR is not a claim about the production topology. -Kubernetes does not restart pods when referenced Secret bytes change. AEAD or APNs credential rotation therefore requires an explicit rolling restart after the secret manager update (for example, `kubectl rollout restart deployment/-buzz-push-gateway`) and readiness verification before removing predecessor keys. Service-account token automount is disabled. +Kubernetes does not restart pods when referenced Secret bytes change. AEAD or APNs certificate rotation therefore requires an explicit rolling restart after the secret manager update (for example, `kubectl rollout restart deployment/-buzz-push-gateway`) and readiness verification before removing predecessor keys. Service-account token automount is disabled. ## Gateway chart release diff --git a/migrations/0033_push_message_kinds.sql b/migrations/0033_push_message_kinds.sql new file mode 100644 index 00000000000..a76481b1592 --- /dev/null +++ b/migrations/0033_push_message_kinds.sql @@ -0,0 +1,24 @@ +-- Dogfood push is deliberately message-only. Replace the trigger allowlist +-- additively so already-applied 0018/0023 migrations remain checksum-stable. +CREATE OR REPLACE FUNCTION enqueue_push_match_job() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + -- Keep this allowlist identical to the relay's validated NIP-PL descriptor. + IF NEW.kind IN (9, 40002, 45001, 45003) THEN + PERFORM pg_advisory_xact_lock_shared( + hashtextextended('buzz_push_gate:' || NEW.community_id::text, 0)); + IF EXISTS ( + SELECT 1 FROM push_leases + WHERE community_id = NEW.community_id + AND active + AND endpoint_enabled + AND expires_at > EXTRACT(EPOCH FROM now())::bigint + ) THEN + INSERT INTO push_match_queue (community_id, event_id) + VALUES (NEW.community_id, NEW.id) + ON CONFLICT DO NOTHING; + END IF; + END IF; + RETURN NEW; +END +$$; diff --git a/mobile/.env.json.example b/mobile/.env.json.example index 7960d5bf127..248ccda1b60 100644 --- a/mobile/.env.json.example +++ b/mobile/.env.json.example @@ -1,4 +1,5 @@ { "BUZZ_RELAY_URL": "http://localhost:3000", + "BUZZ_PUSH_GATEWAY_URL": "http://localhost:8080", "BUZZ_DEV_PUBKEY": "" } diff --git a/mobile/README.md b/mobile/README.md index 1849e1b097d..bfc796c74f8 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -22,7 +22,8 @@ cd mobile && flutter run ### Worktree-aware debug identity Debug builds produced from a git worktree get a unique app identifier keyed -to the **worktree directory name** (`com.buzz.buzzMobile.` on iOS, +to the **worktree directory name** +(`xyz.block.buzz.dogfood.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 @@ -67,6 +68,66 @@ connected Android emulators, run `just mobile-clean` (add `--dry-run` via `./scripts/mobile-worktree-clean.sh --dry-run` to preview). Production installs are never touched. +### Internal iOS push capability + +iOS push is a compile/build capability and defaults off. A normal Debug, +Profile, Release, or App Store build excludes the native push bridge sources, +uses push-free Runner entitlements, and neither builds nor embeds the +Notification Service Extension. Dart also compiles out permission requests, +APNs registration, gateway enrollment/delegation, and relay lease behavior. + +For an authorized internal dogfood build only, create the gitignored +`mobile/ios/Flutter/AppOverrides.xcconfig` with this single include: + +```xcconfig +#include "PushEnabled.xcconfig" +``` + +The tracked overlay selects `xyz.block.buzz.dogfood.mobile`, production App +Attest/APNs entitlements, the internal development team, the push-capable +Runner entitlements, the native bridge, and the extension. CI may equivalently +inject that same include into its ephemeral `AppOverrides.xcconfig`; it must not +edit a tracked base configuration. Relay rollout is independent and remains off +unless its deployment sets `BUZZ_PUSH_ENABLED=true`. See +`docs/push-gateway-deployment.md` for the canonical gateway profile contract, +manual physical-device proof, measurements, and rollback procedure. + +For local physical-device development, enable the same capability while +overriding the dogfood identity back to the normal mobile development identity +and sandbox environments: + +```xcconfig +#include "PushEnabled.xcconfig" +BUNDLE_IDENTIFIER = xyz.block.buzz.mobile +BUZZ_DEVELOPMENT_TEAM = EYF346PHUG +BUZZ_IOS_PUSH_ENVIRONMENT = development +BUZZ_APP_ATTEST_ENVIRONMENT = development +``` + +This exercises the client, extension, relay, and gateway integration without +requiring a dogfood development signing identity. It uses the canonical +gateway's server-owned App Store profile configured for sandbox in the local +development gateway; it does not validate the internally distributed dogfood +artifact or enable the App Store profile in production. Validate dogfood APNs +end to end by cutting an internal release, waiting for it to reach Mobile +Releases/Comp Portal, and installing that signed artifact on a physical device. + +Push-enabled parent app identifiers also 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 +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 +push-enabled app cannot be signed for a physical device. + +APNs and the gateway continue to carry only the constant opaque wake-up. The +extension fetches the message from the scoped relay, verifies message, sender +profile, and channel-metadata signatures, and uses a bounded App Group cache +for names and app-rendered avatar thumbnails. It never fetches an avatar URL; +missing, stale, or invalid enrichment falls back to the verified message with a +short sender pubkey, community subtitle, and no image. + ## Checks ```bash diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore index 950d73854f7..0562776fd4e 100644 --- a/mobile/ios/.gitignore +++ b/mobile/ios/.gitignore @@ -9,6 +9,7 @@ .tags* **/.vagrant/ **/DerivedData/ +BuzzPushKit/Package.resolved Icon? **/Pods/ **/.symlinks/ diff --git a/mobile/ios/BuzzPushKit/Package.swift b/mobile/ios/BuzzPushKit/Package.swift new file mode 100644 index 00000000000..5af3d0a6c46 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "BuzzPushKit", + platforms: [.iOS(.v15), .macOS(.v12)], + products: [ + .library(name: "BuzzPushKit", targets: ["BuzzPushKit"]) + ], + dependencies: [ + .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", exact: "0.21.1") + ], + targets: [ + .target( + name: "BuzzPushKit", + dependencies: [.product(name: "P256K", package: "swift-secp256k1")] + ), + .testTarget( + name: "BuzzPushKitTests", + dependencies: [ + "BuzzPushKit", + .product(name: "P256K", package: "swift-secp256k1"), + ], + resources: [.copy("Fixtures/app_attest_transcripts.json")] + ), + ] +) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift new file mode 100644 index 00000000000..174fc90bb23 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct APNsRegistrationUpdate: Equatable, Sendable { + public let method: String + public let arguments: [String: String] + public init(method: String, arguments: [String: String]) { + self.method = method + self.arguments = arguments + } +} + +public final class APNsRegistrationBuffer { + public private(set) var pending: APNsRegistrationUpdate? + private var deliver: ((APNsRegistrationUpdate) -> Void)? + public init() {} + public func attach(_ deliver: @escaping (APNsRegistrationUpdate) -> Void) { + self.deliver = deliver + flush() + } + public func recordToken(_ token: Data) { + record(APNsRegistrationUpdate( + method: "apnsTokenChanged", + arguments: ["token": token.map { String(format: "%02x", $0) }.joined()] + )) + } + public func recordError(_ message: String) { + record(APNsRegistrationUpdate( + method: "apnsRegistrationFailed", arguments: ["message": message] + )) + } + private func record(_ update: APNsRegistrationUpdate) { + pending = update + flush() + } + private func flush() { + guard let pending, let deliver else { return } + self.pending = nil + deliver(pending) + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift new file mode 100644 index 00000000000..5946d5a92c2 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift @@ -0,0 +1,146 @@ +import Foundation + +/// Verified local values used to specialize an ordinary notification as communication. +public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { + public let senderDisplayName: String + public let senderIdentifier: String + public let senderAvatarPNG: Data? + public let messageBody: String + public let conversationIdentifier: String + public let conversationDisplayName: String? + /// Verified recipients represented by the incoming message, excluding its sender. + public let recipientCount: Int + + public init( + senderDisplayName: String, + senderIdentifier: String, + senderAvatarPNG: Data?, + messageBody: String, + conversationIdentifier: String, + conversationDisplayName: String?, + recipientCount: Int + ) { + self.senderDisplayName = senderDisplayName + self.senderIdentifier = senderIdentifier + self.senderAvatarPNG = senderAvatarPNG + self.messageBody = messageBody + self.conversationIdentifier = conversationIdentifier + self.conversationDisplayName = conversationDisplayName + self.recipientCount = recipientCount + } + + public init?(resolution: BuzzPushResolution) { + guard let target = resolution.navigationTarget, + let senderPubkey = resolution.senderPubkey, + !senderPubkey.isEmpty, + let conversationIdentifier = resolution.conversationIdentifier, + !conversationIdentifier.isEmpty, + let recipientCount = resolution.conversationRecipientCount, + recipientCount > 0 + else { return nil } + self.init( + senderDisplayName: resolution.title, + senderIdentifier: BuzzPushPresentationIdentity.sender( + communityID: target.communityID, + pubkey: senderPubkey + ), + senderAvatarPNG: resolution.senderAvatarPNG, + messageBody: resolution.body, + conversationIdentifier: conversationIdentifier, + conversationDisplayName: resolution.conversationDisplayName, + recipientCount: recipientCount + ) + } +} + +#if os(iOS) + import Intents + import UserNotifications + + /// Donates and applies Apple's supported Communication Notifications intent. + public final class BuzzCommunicationNotificationPresenter { + public typealias Donation = (INInteraction, @escaping (Error?) -> Void) -> Void + public typealias ContentUpdate = ( + UNMutableNotificationContent, + INSendMessageIntent + ) throws -> UNNotificationContent + + private let donate: Donation + private let updateContent: ContentUpdate + + public convenience init() { + self.init( + donate: { interaction, completion in + interaction.donate(completion: completion) + }, + updateContent: { content, intent in + try content.updating(from: intent) + } + ) + } + + public init( + donate: @escaping Donation, + updateContent: @escaping ContentUpdate + ) { + self.donate = donate + self.updateContent = updateContent + } + + public func present( + ordinaryContent: UNMutableNotificationContent, + resolution: BuzzPushResolution, + completion: @escaping (UNNotificationContent) -> Void + ) { + guard let descriptor = BuzzCommunicationNotificationDescriptor(resolution: resolution) else { + completion(ordinaryContent) + return + } + let intent = Self.makeIntent(descriptor) + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + donate(interaction) { [updateContent] error in + guard error == nil, + let specialized = try? updateContent(ordinaryContent, intent) + else { + completion(ordinaryContent) + return + } + completion(specialized) + } + } + + public static func makeIntent( + _ descriptor: BuzzCommunicationNotificationDescriptor + ) -> INSendMessageIntent { + let sender = INPerson( + personHandle: INPersonHandle(value: descriptor.senderIdentifier, type: .unknown), + nameComponents: nil, + displayName: descriptor.senderDisplayName, + image: descriptor.senderAvatarPNG.map(INImage.init(imageData:)), + contactIdentifier: nil, + customIdentifier: descriptor.senderIdentifier, + isMe: false, + suggestionType: .none + ) + let intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: descriptor.messageBody, + speakableGroupName: descriptor.conversationDisplayName.map { + INSpeakableString(spokenPhrase: $0) + }, + conversationIdentifier: descriptor.conversationIdentifier, + serviceName: "Buzz", + sender: sender, + attachments: nil + ) + if descriptor.conversationDisplayName != nil { + let donationMetadata = INSendMessageIntentDonationMetadata() + donationMetadata.recipientCount = descriptor.recipientCount + intent.donationMetadata = donationMetadata + } + return intent + } + } +#endif diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift new file mode 100644 index 00000000000..c222fe30d94 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -0,0 +1,923 @@ +import CryptoKit +import DeviceCheck +import Foundation + +#if canImport(Security) + import Security +#endif + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// The opaque gateway capability and binding metadata needed by a later lease publisher. +public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { + public let relayOrigin: String + /// NIP-PL delegation key selected from the relay push descriptor. + public let relayPubkey: String + /// Optional NIP-11 `self` key that verifies relay-authored NIP-29 metadata. + public let relayMetadataPubkey: String? + /// Gateway installation authority. This is distinct from [installationId], + /// which is the unlinkable per-relay-origin NIP-PL lease address. + public let gatewayInstallationHandle: String? + public let installationId: String + public let endpointGrant: String + public let endpointHash: String + public let appProfile: String + public let endpointEpoch: Int64 + public let generation: Int64 + public let expiresAt: Int64 + + public init( + relayOrigin: String, + relayPubkey: String, + relayMetadataPubkey: String? = nil, + gatewayInstallationHandle: String? = nil, + installationId: String, + endpointGrant: String, + endpointHash: String, + appProfile: String, + endpointEpoch: Int64, + generation: Int64, + expiresAt: Int64 + ) { + precondition(generation > 0, "Endpoint grant generation must be positive") + self.relayOrigin = relayOrigin + self.relayPubkey = relayPubkey + self.relayMetadataPubkey = relayMetadataPubkey + self.gatewayInstallationHandle = gatewayInstallationHandle + self.installationId = installationId + self.endpointGrant = endpointGrant + self.endpointHash = endpointHash + self.appProfile = appProfile + self.endpointEpoch = endpointEpoch + self.generation = generation + self.expiresAt = expiresAt + } +} + +/// Persistence boundary for endpoint grants. The Runner implementation stores +/// records in its Keychain access group and exposes them over the Flutter bridge. +public protocol BuzzPushEndpointGrantStore { + func records() throws -> [BuzzPushEndpointGrantRecord] + func save(_ record: BuzzPushEndpointGrantRecord) throws +} + +public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { + case invalidGatewayURL + case invalidRelayURL + case invalidRelayDescriptor + case invalidResponse(route: String) + case unexpectedStatus(route: String, expected: Int, actual: Int, body: String) + case randomGenerationFailed(Int32) + case appAttestUnsupported + case invalidAppAttestKeyId + case generationExhausted + + public var errorDescription: String? { + switch self { + case .invalidGatewayURL: + return "The development push gateway URL must be an HTTP or HTTPS origin." + case .invalidRelayURL: + return "The relay URL must be a ws or wss origin." + case .invalidRelayDescriptor: + return "NIP-11 must contain exactly one valid current push key." + case .invalidResponse(let route): + return "The response from \(route) did not match the closed push protocol." + case .unexpectedStatus(let route, let expected, let actual, let body): + return "The response from \(route) was HTTP \(actual), expected \(expected): \(body)" + case .randomGenerationFailed(let status): + return "Secure random generation failed with status \(status)." + case .appAttestUnsupported: + return "App Attest is unavailable on this device." + case .invalidAppAttestKeyId: + return "The App Attest key identifier is missing or invalid." + case .generationExhausted: + return "The development push grant generation cannot advance further." + } + } +} + +protocol BuzzDevAppAttesting { + func prepareAttestation() async throws -> BuzzDevAttestation + func attestation(_ prepared: BuzzDevAttestation, clientData: Data) async throws + -> BuzzDevAttestation + func assertion(clientData: Data) async throws -> String +} + +struct BuzzDevAttestation: Equatable { + let keyId: String + let attestation: String +} + +private enum BuzzSecureRandom { + static func bytes(count: Int) throws -> Data { + var bytes = [UInt8](repeating: 0, count: count) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw BuzzDevPushEnrollmentError.randomGenerationFailed(status) + } + return Data(bytes) + } +} + +#if DEBUG + struct BuzzDevAppAttestProvider: BuzzDevAppAttesting { + private static let attestationPrefix = Data("buzz-dev-app-attest-v1:".utf8) + private static let assertionBytes = Data("buzz-dev-app-assertion-v1".utf8) + + let randomBytes: () throws -> Data + + init( + randomBytes: @escaping () throws -> Data = { + try BuzzSecureRandom.bytes(count: 32) + } + ) { + self.randomBytes = randomBytes + } + + func prepareAttestation() async throws -> BuzzDevAttestation { + let entropy = try randomBytes() + precondition(entropy.count == 32, "Development attestation entropy must be exactly 32 bytes") + let bytes = Self.attestationPrefix + entropy + return BuzzDevAttestation( + keyId: Data(SHA256.hash(data: bytes)).base64EncodedString(), + attestation: bytes.base64EncodedString() + ) + } + + func attestation( + _ prepared: BuzzDevAttestation, + clientData: Data + ) async throws -> BuzzDevAttestation { + precondition(!clientData.isEmpty, "Enrollment client data must not be empty") + return prepared + } + + func assertion(clientData: Data) async throws -> String { + precondition(!clientData.isEmpty, "Delegation client data must not be empty") + return Self.assertionBytes.base64EncodedString() + } + } +#endif + +private enum BuzzAppAttestKeyId { + static func isValid(_ keyId: String) -> Bool { + guard !keyId.isEmpty, + keyId.unicodeScalars.allSatisfy(\.isASCII), + let bytes = Data(base64Encoded: keyId) + else { return false } + return bytes.count == 32 && bytes.base64EncodedString() == keyId + } +} + +protocol BuzzAppAttestKeyIdStoring { + func keyId() throws -> String? + func saveKeyId(_ keyId: String) throws +} + +struct BuzzAppAttestKeyIdKeychainStore: BuzzAppAttestKeyIdStoring { + private static let service = "buzz.push.app-attest" + private static let account = "key-id-v1" + + private let accessGroup: String? + private let copyMatching: (CFDictionary, UnsafeMutablePointer?) -> OSStatus + private let update: (CFDictionary, CFDictionary) -> OSStatus + private let add: (CFDictionary, UnsafeMutablePointer?) -> OSStatus + + init( + accessGroup: String?, + copyMatching: @escaping (CFDictionary, UnsafeMutablePointer?) -> OSStatus = + SecItemCopyMatching, + update: @escaping (CFDictionary, CFDictionary) -> OSStatus = SecItemUpdate, + add: @escaping (CFDictionary, UnsafeMutablePointer?) -> OSStatus = SecItemAdd + ) { + self.accessGroup = accessGroup + self.copyMatching = copyMatching + self.update = update + self.add = add + } + + func keyId() throws -> String? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = copyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { + throw keychainError(status, operation: "read") + } + guard let data = result as? Data, + let keyId = String(data: data, encoding: .utf8), + BuzzAppAttestKeyId.isValid(keyId) + else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + return keyId + } + + func saveKeyId(_ keyId: String) throws { + guard BuzzAppAttestKeyId.isValid(keyId) else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let data = Data(keyId.utf8) + let updateStatus = update( + baseQuery() as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw keychainError(updateStatus, operation: "update") + } + + var item = baseQuery() + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = add(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw keychainError(addStatus, operation: "add") + } + } + + private func baseQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } + + private func keychainError(_ status: OSStatus, operation: String) -> Error { + NSError( + domain: NSOSStatusErrorDomain, + code: Int(status), + userInfo: [ + NSLocalizedDescriptionKey: + "App Attest key identifier Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)" + ] + ) + } +} + +protocol BuzzDCAppAttestServicing { + var isSupported: Bool { get } + func generateKey() async throws -> String + func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data + func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data +} + +extension DCAppAttestService: BuzzDCAppAttestServicing {} + +struct BuzzDCAppAttestProvider: BuzzDevAppAttesting { + private let service: BuzzDCAppAttestServicing + private let keyIdStore: BuzzAppAttestKeyIdStoring + + init( + service: BuzzDCAppAttestServicing = DCAppAttestService.shared, + keyIdStore: BuzzAppAttestKeyIdStoring + ) { + self.service = service + self.keyIdStore = keyIdStore + } + + func prepareAttestation() async throws -> BuzzDevAttestation { + try requireSupportedService() + let keyId = try await service.generateKey() + guard BuzzAppAttestKeyId.isValid(keyId) else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + try keyIdStore.saveKeyId(keyId) + return BuzzDevAttestation(keyId: keyId, attestation: "") + } + + func attestation( + _ prepared: BuzzDevAttestation, + clientData: Data + ) async throws -> BuzzDevAttestation { + precondition(!clientData.isEmpty, "Enrollment client data must not be empty") + try requireSupportedService() + guard BuzzAppAttestKeyId.isValid(prepared.keyId), + try keyIdStore.keyId() == prepared.keyId + else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let object = try await service.attestKey( + prepared.keyId, + clientDataHash: Data(SHA256.hash(data: clientData)) + ) + return BuzzDevAttestation( + keyId: prepared.keyId, + attestation: object.base64EncodedString() + ) + } + + func assertion(clientData: Data) async throws -> String { + precondition(!clientData.isEmpty, "Delegation client data must not be empty") + try requireSupportedService() + guard let keyId = try keyIdStore.keyId(), + BuzzAppAttestKeyId.isValid(keyId) + else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let object = try await service.generateAssertion( + keyId, + clientDataHash: Data(SHA256.hash(data: clientData)) + ) + return object.base64EncodedString() + } + + private func requireSupportedService() throws { + guard service.isSupported else { + throw BuzzDevPushEnrollmentError.appAttestUnsupported + } + } +} + +/// 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 + private let store: BuzzPushEndpointGrantStore + private let session: URLSession + private let appAttest: BuzzDevAppAttesting + private let now: () -> Date + private let lifetimeSeconds: Int64 + private let installationIdBytes: () throws -> Data + + /// Creates a driver backed by Apple's App Attest service and persists the + /// generated App Attest key identifier in the requested Keychain access group. + public convenience init( + gatewayBaseURL: URL, + store: BuzzPushEndpointGrantStore, + appAttestKeychainAccessGroup: String?, + session: URLSession = .shared + ) throws { + try self.init( + gatewayBaseURL: gatewayBaseURL, + store: store, + session: session, + appAttest: BuzzDCAppAttestProvider( + keyIdStore: BuzzAppAttestKeyIdKeychainStore( + accessGroup: appAttestKeychainAccessGroup + ) + ), + now: Date.init, + lifetimeSeconds: 2_592_000, + installationIdBytes: { try BuzzSecureRandom.bytes(count: 16) } + ) + } + + #if DEBUG + public convenience init( + gatewayBaseURL: URL, + store: BuzzPushEndpointGrantStore, + session: URLSession = .shared + ) throws { + try self.init( + gatewayBaseURL: gatewayBaseURL, + store: store, + session: session, + appAttest: BuzzDevAppAttestProvider(), + now: Date.init, + lifetimeSeconds: 2_592_000, + installationIdBytes: { try BuzzSecureRandom.bytes(count: 16) } + ) + } + #endif + + init( + gatewayBaseURL: URL, + store: BuzzPushEndpointGrantStore, + session: URLSession, + appAttest: BuzzDevAppAttesting, + now: @escaping () -> Date, + lifetimeSeconds: Int64, + installationIdBytes: @escaping () throws -> Data = { + try BuzzSecureRandom.bytes(count: 16) + } + ) throws { + guard Self.isHTTPOrigin(gatewayBaseURL), lifetimeSeconds > 0 else { + throw BuzzDevPushEnrollmentError.invalidGatewayURL + } + self.gatewayBaseURL = gatewayBaseURL + self.store = store + self.session = session + self.appAttest = appAttest + self.now = now + self.lifetimeSeconds = lifetimeSeconds + self.installationIdBytes = installationIdBytes + } + + public func endpointGrants() throws -> [BuzzPushEndpointGrantRecord] { + try store.records() + } + + /// Fetches the relay's current NIP-11 push key, enrolls the APNs endpoint, + /// delegates to that key, and durably saves the resulting opaque grant. + public func enroll( + deviceToken: Data, + relayURL: URL + ) async throws -> BuzzPushEndpointGrantRecord { + precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") + let relayOrigin = try Self.relayOrigin(relayURL) + let relayKeys = try await fetchCurrentRelayKeys(from: relayOrigin.url) + let relayPubkey = relayKeys.pushPubkey + let endpoint = Self.lowercaseHex(deviceToken) + let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) + let nowSeconds = Int64(now().timeIntervalSince1970) + + let storedRecords = try store.records() + let storedForOrigin = storedRecords.first { + $0.relayOrigin == relayOrigin.text && $0.appProfile == Self.appProfile + } + if let current = storedForOrigin, + current.relayPubkey == relayPubkey, + current.endpointHash == endpointHash, + current.endpointEpoch == Self.endpointEpoch, + current.expiresAt > nowSeconds + 300 + { + guard current.relayMetadataPubkey != relayKeys.metadataPubkey else { + return current + } + let refreshed = BuzzPushEndpointGrantRecord( + relayOrigin: current.relayOrigin, + relayPubkey: current.relayPubkey, + relayMetadataPubkey: relayKeys.metadataPubkey, + gatewayInstallationHandle: current.gatewayInstallationHandle, + installationId: current.installationId, + endpointGrant: current.endpointGrant, + endpointHash: current.endpointHash, + appProfile: current.appProfile, + endpointEpoch: current.endpointEpoch, + generation: current.generation, + expiresAt: current.expiresAt + ) + try store.save(refreshed) + return refreshed + } + + // One gateway delegation is scoped to an installation and relay key, not + // to a Buzz community. A second origin served by the same relay therefore + // gets a fresh unlinkable NIP-PL address while reusing the opaque grant. + if storedForOrigin == nil, + let sharedGrant = storedRecords.first(where: { + $0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile + && $0.endpointHash == endpointHash && $0.endpointEpoch == Self.endpointEpoch + && $0.expiresAt > nowSeconds + 300 + }) + { + let record = BuzzPushEndpointGrantRecord( + relayOrigin: relayOrigin.text, + relayPubkey: relayPubkey, + relayMetadataPubkey: relayKeys.metadataPubkey, + gatewayInstallationHandle: sharedGrant.gatewayInstallationHandle, + installationId: try makeInstallationId(), + endpointGrant: sharedGrant.endpointGrant, + endpointHash: endpointHash, + appProfile: Self.appProfile, + endpointEpoch: sharedGrant.endpointEpoch, + generation: sharedGrant.generation, + expiresAt: sharedGrant.expiresAt + ) + try store.save(record) + return record + } + + // A previously attested installation can delegate independently to a new + // relay key, or issue a higher-generation grant for the same relay, + // without attempting duplicate APNs-token enrollment. + let reusableInstallation = storedRecords.first { record in + guard record.appProfile == Self.appProfile, + record.endpointHash == endpointHash, + record.endpointEpoch == Self.endpointEpoch, + record.expiresAt > nowSeconds + 300, + let handle = record.gatewayInstallationHandle, + let uuid = UUID(uuidString: handle) + else { return false } + return handle == uuid.uuidString.lowercased() + } + + let installation: UUID + let expiresAt: Int64 + if let reusableInstallation, + let handle = reusableInstallation.gatewayInstallationHandle, + let existing = UUID(uuidString: handle) + { + installation = existing + expiresAt = reusableInstallation.expiresAt + } else { + let (newExpiration, expiresOverflow) = nowSeconds.addingReportingOverflow(lifetimeSeconds) + guard !expiresOverflow else { + throw BuzzDevPushEnrollmentError.invalidGatewayURL + } + expiresAt = newExpiration + let enrollmentChallenge = try await challenge() + let preparedAttestation = try await appAttest.prepareAttestation() + let enrollmentClientData = try BuzzPushTranscript.enroll( + challengeId: enrollmentChallenge.id, + challenge: enrollmentChallenge.value, + keyId: preparedAttestation.keyId, + appProfile: Self.appProfile, + endpoint: endpoint, + endpointEpoch: Self.endpointEpoch, + expiresAt: expiresAt + ) + let attestation = try await appAttest.attestation( + preparedAttestation, + clientData: enrollmentClientData + ) + guard attestation.keyId == preparedAttestation.keyId else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "development attestation") + } + installation = try await enrollInstallation( + challenge: enrollmentChallenge, + endpoint: endpoint, + expiresAt: expiresAt, + attestation: attestation + ) + } + + let installationHandle = installation.uuidString.lowercased() + let currentGeneration = + storedRecords + .filter { + $0.gatewayInstallationHandle == installationHandle + && $0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile + } + .map(\.generation) + .max() + let generation: Int64 + if let currentGeneration { + let (next, overflow) = currentGeneration.addingReportingOverflow(1) + guard !overflow, next > 0 else { + throw BuzzDevPushEnrollmentError.generationExhausted + } + generation = next + } else { + generation = 1 + } + + let delegationChallenge = try await challenge() + let delegationClientData = try BuzzPushTranscript.delegate( + challengeId: delegationChallenge.id, + challenge: delegationChallenge.value, + installationHandle: installation, + endpointEpoch: Self.endpointEpoch, + generation: generation, + relayPubkey: relayPubkey, + notBefore: nowSeconds, + expiresAt: expiresAt + ) + let assertion = try await appAttest.assertion(clientData: delegationClientData) + let endpointGrant = try await delegate( + challenge: delegationChallenge, + installationHandle: installation, + relayPubkey: relayPubkey, + generation: generation, + notBefore: nowSeconds, + expiresAt: expiresAt, + assertion: assertion + ) + + let record = BuzzPushEndpointGrantRecord( + relayOrigin: relayOrigin.text, + relayPubkey: relayPubkey, + relayMetadataPubkey: relayKeys.metadataPubkey, + gatewayInstallationHandle: installationHandle, + installationId: try storedForOrigin?.installationId ?? makeInstallationId(), + endpointGrant: endpointGrant, + endpointHash: endpointHash, + appProfile: Self.appProfile, + endpointEpoch: Self.endpointEpoch, + generation: generation, + expiresAt: expiresAt + ) + try store.save(record) + return record + } + + private func makeInstallationId() throws -> String { + let bytes = try installationIdBytes() + precondition( + bytes.count == 16, + "NIP-PL installation identity entropy must be exactly 16 bytes" + ) + // This value is per relay origin and never leaves the relay-facing lease. + return Self.lowercaseHex(bytes) + } + + private func challenge() async throws -> Challenge { + let response: ChallengeResponse = try await post( + route: "v1/installations/challenges", + expectedStatus: 200, + body: VersionRequest(v: 1) + ) + guard let id = UUID(uuidString: response.challengeId), + response.challengeId == id.uuidString.lowercased(), + Self.isBase64URLChallenge(response.challenge), + response.expiresAt > Int64(now().timeIntervalSince1970) + else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations/challenges") + } + return Challenge(id: id, value: response.challenge) + } + + private func enrollInstallation( + challenge: Challenge, + endpoint: String, + expiresAt: Int64, + attestation: BuzzDevAttestation + ) async throws -> UUID { + let response: InstallationResponse = try await post( + route: "v1/installations", + expectedStatus: 201, + body: InstallationRequest( + v: 1, + challengeId: challenge.id.uuidString.lowercased(), + challenge: challenge.value, + keyId: attestation.keyId, + attestation: attestation.attestation, + appProfile: Self.appProfile, + endpoint: endpoint, + endpointEpoch: Self.endpointEpoch, + expiresAt: expiresAt + ) + ) + guard let installation = UUID(uuidString: response.installationHandle), + response.installationHandle == installation.uuidString.lowercased(), + response.endpointEpoch == Self.endpointEpoch, + response.expiresAt == expiresAt + else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations") + } + return installation + } + + private func delegate( + challenge: Challenge, + installationHandle: UUID, + relayPubkey: String, + generation: Int64, + notBefore: Int64, + expiresAt: Int64, + assertion: String + ) async throws -> String { + let response: DelegationResponse = try await post( + route: "v1/delegations", + expectedStatus: 201, + body: DelegationRequest( + v: 1, + challengeId: challenge.id.uuidString.lowercased(), + challenge: challenge.value, + installationHandle: installationHandle.uuidString.lowercased(), + endpointEpoch: Self.endpointEpoch, + generation: generation, + relayPubkey: relayPubkey, + notBefore: notBefore, + expiresAt: expiresAt, + assertion: assertion + ) + ) + guard !response.endpointGrant.isEmpty, response.endpointGrant.utf8.count <= 4_096 else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/delegations") + } + return response.endpointGrant + } + + private func fetchCurrentRelayKeys(from relayOrigin: URL) async throws -> RelayKeys { + var request = URLRequest(url: relayOrigin) + request.httpMethod = "GET" + request.setValue("application/nostr+json", forHTTPHeaderField: "Accept") + let (data, response) = try await session.data(for: request) + try Self.expectStatus(response, data: data, route: "NIP-11", expected: 200) + let document: RelayInformation + do { + document = try JSONDecoder().decode(RelayInformation.self, from: data) + } catch { + throw BuzzDevPushEnrollmentError.invalidRelayDescriptor + } + let current = document.push.keys.filter(\.current) + guard current.count == 1, + Self.isLowercaseHexPubkey(current[0].pubkey) + else { + throw BuzzDevPushEnrollmentError.invalidRelayDescriptor + } + let metadataPubkey = document.relaySelf.flatMap { + Self.isLowercaseHexPubkey($0) ? $0 : nil + } + return RelayKeys( + pushPubkey: current[0].pubkey, + metadataPubkey: metadataPubkey + ) + } + + private func post( + route: String, + expectedStatus: Int, + body: Request + ) async throws -> Response { + let url = route.split(separator: "/").reduce(gatewayBaseURL) { + $0.appendingPathComponent(String($1)) + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + let (data, response) = try await session.data(for: request) + try Self.expectStatus(response, data: data, route: route, expected: expectedStatus) + do { + return try JSONDecoder().decode(Response.self, from: data) + } catch { + throw BuzzDevPushEnrollmentError.invalidResponse(route: route) + } + } + + private static func expectStatus( + _ response: URLResponse, + data: Data, + route: String, + expected: Int + ) throws { + guard let http = response as? HTTPURLResponse else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: route) + } + guard http.statusCode == expected else { + let body = String(decoding: data.prefix(512), as: UTF8.self) + throw BuzzDevPushEnrollmentError.unexpectedStatus( + route: route, expected: expected, actual: http.statusCode, body: body + ) + } + } + + private static func isHTTPOrigin(_ url: URL) -> Bool { + (url.scheme == "http" || url.scheme == "https") + && url.host != nil + && (url.path.isEmpty || url.path == "/") + && url.user == nil + && url.password == nil + && url.query == nil + && url.fragment == nil + } + + private static func relayOrigin(_ url: URL) throws -> (url: URL, text: String) { + guard url.scheme == "ws" || url.scheme == "wss", + url.host != nil, + url.path.isEmpty || url.path == "/", + url.user == nil, + url.password == nil, + url.query == nil, + url.fragment == nil + else { + throw BuzzDevPushEnrollmentError.invalidRelayURL + } + var components = URLComponents() + components.scheme = url.scheme == "wss" ? "https" : "http" + components.host = url.host + components.port = url.port + components.path = "/" + guard let httpURL = components.url else { + throw BuzzDevPushEnrollmentError.invalidRelayURL + } + var relayComponents = components + relayComponents.scheme = url.scheme + relayComponents.path = "" + guard let relayText = relayComponents.string else { + throw BuzzDevPushEnrollmentError.invalidRelayURL + } + return (httpURL, relayText) + } + + private static func isLowercaseHexPubkey(_ value: String) -> Bool { + value.utf8.count == 64 + && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } + + private static func isBase64URLChallenge(_ value: String) -> Bool { + guard value.utf8.count == 43, + value.utf8.allSatisfy({ + (48...57).contains($0) || (65...90).contains($0) + || (97...122).contains($0) || $0 == 45 || $0 == 95 + }) + else { return false } + var padded = value.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + padded += String(repeating: "=", count: (4 - padded.count % 4) % 4) + return Data(base64Encoded: padded)?.count == 32 + } + + private static func lowercaseHex(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } +} + +private struct VersionRequest: Encodable { let v: Int } +private struct Challenge { + let id: UUID + let value: String +} +private struct ChallengeResponse: Decodable { + let challengeId: String + let challenge: String + let expiresAt: Int64 + enum CodingKeys: String, CodingKey { + case challengeId = "challenge_id" + case challenge + case expiresAt = "expires_at" + } +} +private struct InstallationRequest: Encodable { + let v: Int + let challengeId: String + let challenge: String + let keyId: String + let attestation: String + let appProfile: String + let endpoint: String + let endpointEpoch: Int64 + let expiresAt: Int64 + enum CodingKeys: String, CodingKey { + case v + case challengeId = "challenge_id" + case challenge + case keyId = "key_id" + case attestation + case appProfile = "app_profile" + case endpoint + case endpointEpoch = "endpoint_epoch" + case expiresAt = "expires_at" + } +} +private struct InstallationResponse: Decodable { + let installationHandle: String + let endpointEpoch: Int64 + let expiresAt: Int64 + enum CodingKeys: String, CodingKey { + case installationHandle = "installation_handle" + case endpointEpoch = "endpoint_epoch" + case expiresAt = "expires_at" + } +} +private struct DelegationRequest: Encodable { + let v: Int + let challengeId: String + let challenge: String + let installationHandle: String + let endpointEpoch: Int64 + let generation: Int64 + let relayPubkey: String + let notBefore: Int64 + let expiresAt: Int64 + let assertion: String + enum CodingKeys: String, CodingKey { + case v + case challengeId = "challenge_id" + case challenge + case installationHandle = "installation_handle" + case endpointEpoch = "endpoint_epoch" + case generation + case relayPubkey = "relay_pubkey" + case notBefore = "not_before" + case expiresAt = "expires_at" + case assertion + } +} +private struct DelegationResponse: Decodable { + let endpointGrant: String + enum CodingKeys: String, CodingKey { case endpointGrant = "endpoint_grant" } +} +private struct RelayInformation: Decodable { + struct Push: Decodable { + struct Key: Decodable { + let pubkey: String + let current: Bool + } + let keys: [Key] + } + let relaySelf: String? + let push: Push + + enum CodingKeys: String, CodingKey { + case relaySelf = "self" + case push + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + push = try container.decode(Push.self, forKey: .push) + relaySelf = try? container.decode(String.self, forKey: .relaySelf) + } +} + +private struct RelayKeys { + let pushPubkey: String + let metadataPubkey: String? +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift new file mode 100644 index 00000000000..9218655021a --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift @@ -0,0 +1,85 @@ +import Foundation + +/// A stable destination attached by the notification service extension after +/// it resolves and verifies the event that produced a push wake. +public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { + public static let userInfoKey = "buzz_push_navigation" + + public let eventID: String + public let communityID: String + public let channelID: String + + public init(eventID: String, communityID: String, channelID: String) { + self.eventID = eventID + self.communityID = communityID + self.channelID = channelID + } + + public var userInfoValue: [String: String] { + [ + "event_id": eventID, + "community_id": communityID, + "channel_id": channelID, + ] + } + + /// Decodes a target without trusting other fields from the APNs payload. + public static func decodeIfPresent( + from userInfo: [AnyHashable: Any] + ) -> BuzzPushNavigationTarget? { + guard let raw = userInfo[userInfoKey] as? [String: Any], + raw.count == 3, + let eventID = raw["event_id"] as? String, + let communityID = raw["community_id"] as? String, + let channelID = raw["channel_id"] as? String, + !eventID.isEmpty, + !communityID.isEmpty, + !channelID.isEmpty + else { + return nil + } + return BuzzPushNavigationTarget( + eventID: eventID, + communityID: communityID, + channelID: channelID + ) + } + +} + +/// Thread-safe one-item buffer spanning notification delivery and Flutter +/// engine startup during a cold notification launch. +public final class BuzzPushNavigationBuffer: @unchecked Sendable { + private let lock = NSLock() + private var target: BuzzPushNavigationTarget? + + public init() {} + + public func record(_ target: BuzzPushNavigationTarget) { + lock.lock() + self.target = target + lock.unlock() + } + + public func peek() -> BuzzPushNavigationTarget? { + lock.lock() + defer { lock.unlock() } + return target + } + + public func take() -> BuzzPushNavigationTarget? { + lock.lock() + defer { lock.unlock() } + let current = target + target = nil + return current + } + + public func remove(ifMatching expected: BuzzPushNavigationTarget) { + lock.lock() + defer { lock.unlock() } + if target == expected { + target = nil + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift new file mode 100644 index 00000000000..6cd76ecf740 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -0,0 +1,620 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Content resolved from unread Buzz events for a mutable push notification. +public struct BuzzPushResolution: Decodable, Equatable, Sendable { + public let title: String + public let body: String + public let subtitle: String? + public let threadIdentifier: String? + public let navigationTarget: BuzzPushNavigationTarget? + public let senderPubkey: String? + public let senderAvatarPNG: Data? + public let conversationIdentifier: String? + public let conversationDisplayName: String? + /// Exact verified recipient count for Communication Notifications specialization. + public let conversationRecipientCount: Int? + + public init( + title: String, + body: String, + subtitle: String?, + threadIdentifier: String?, + navigationTarget: BuzzPushNavigationTarget? = nil, + senderPubkey: String? = nil, + senderAvatarPNG: Data? = nil, + conversationIdentifier: String? = nil, + conversationDisplayName: String? = nil, + conversationRecipientCount: Int? = nil + ) { + self.title = title + self.body = body + self.subtitle = subtitle + self.threadIdentifier = threadIdentifier + self.navigationTarget = navigationTarget + self.senderPubkey = senderPubkey + self.senderAvatarPNG = senderAvatarPNG + self.conversationIdentifier = conversationIdentifier + self.conversationDisplayName = conversationDisplayName + self.conversationRecipientCount = conversationRecipientCount + } +} + +/// Resolves the content used to mutate a generic Buzz push notification. +public protocol BuzzPushNotificationResolving { + func resolve(completion: @escaping (BuzzPushResolution?) -> Void) +} + +/// Reads configured Buzz communities and resolves their newest unread event. +public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { + static let maximumPresentationResponseBytes = 128 * 1_024 + + private let session: URLSession + private let loadCommunitiesData: () -> Data? + private let loadPresentationCacheData: () -> Data? + private let loadPrivateKey: (String) -> String? + private let now: () -> Date + private let presentationCacheLifetime: TimeInterval + + /// Creates a resolver around the notification extension's App Group and Keychain I/O. + public init( + session: URLSession, + loadCommunitiesData: @escaping () -> Data?, + loadPrivateKey: @escaping (String) -> String?, + loadPresentationCacheData: @escaping () -> Data? = { nil }, + now: @escaping () -> Date = Date.init, + presentationCacheLifetime: TimeInterval = BuzzPushPresentationCacheStore.freshnessLifetime + ) { + self.session = session + self.loadCommunitiesData = loadCommunitiesData + self.loadPresentationCacheData = loadPresentationCacheData + self.loadPrivateKey = loadPrivateKey + self.now = now + self.presentationCacheLifetime = presentationCacheLifetime + } + + public func resolve(completion: @escaping (BuzzPushResolution?) -> Void) { + let communities = loadCommunities().filter { + $0.pubkey?.isEmpty == false + && loadPrivateKey($0.id) != nil + && (try? $0.pushSubscriptionState.authoritativeSubscriptions().isEmpty == false) == true + } + guard !communities.isEmpty else { + completion(nil) + return + } + let group = DispatchGroup() + let lock = NSLock() + var candidates: [(VerifiedNostrEvent, PushLeaseCommunity)] = [] + for community in communities { + group.enter() + query(community) { candidate in + if let candidate { + lock.lock() + candidates.append(candidate) + lock.unlock() + } + group.leave() + } + } + group.notify(queue: .global(qos: .userInitiated)) { + let newest = candidates.max { + $0.0.createdAt == $1.0.createdAt ? $0.0.id > $1.0.id : $0.0.createdAt < $1.0.createdAt + } + guard let newest else { + completion(nil) + return + } + self.resolvePresentation(event: newest.0, community: newest.1, completion: completion) + } + } + + private func query( + _ community: PushLeaseCommunity, + completion: @escaping ((VerifiedNostrEvent, PushLeaseCommunity)?) -> Void + ) { + guard let privateKey = loadPrivateKey(community.id), community.pubkey?.isEmpty == false else { + completion(nil) + return + } + guard + let subscriptions = try? community.pushSubscriptionState.authoritativeSubscriptions(), + !subscriptions.isEmpty, + let relayURL = community.relayURL, + let url = URL(string: "/query", relativeTo: relayURL), + let body = try? JSONSerialization.data( + withJSONObject: subscriptions.map { $0.filter.queryFilter(since: nil, limit: 10) } + ) + else { + completion(nil) + return + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = 8 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard + let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, method: "POST", body: body, privateKeyHex: privateKey + ) + else { + completion(nil) + return + } + request.setValue(auth, forHTTPHeaderField: "Authorization") + session.dataTask(with: request) { data, response, _ in + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), + let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + else { + completion(nil) + return + } + let candidate = Self.newestMessage( + events: events.filter { event in + event.hasValidIDAndSignature() + && subscriptions.contains { subscription in + PushLeaseMatcher.matches(event: event, subscription: subscription) + } + }, + community: community + ) + completion(candidate.map { ($0, community) }) + }.resume() + } + + private func resolvePresentation( + event: VerifiedNostrEvent, + community: PushLeaseCommunity, + completion: @escaping (BuzzPushResolution?) -> Void + ) { + let snapshot = BuzzPushPresentationCacheSnapshot.decode(loadPresentationCacheData()) + let relayOrigin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(community.relayUrl) + let cachedProfile = relayOrigin.flatMap { + snapshot.profile( + communityID: community.id, + relayOrigin: $0, + pubkey: event.pubkey + ) + } + let channelID = Self.tagValue("h", in: event) + let relayMetadataPubkey = community.relayMetadataPubkey?.lowercased() + let cachedChannel = channelID.flatMap { channelID in + relayOrigin.flatMap { + snapshot.channel( + communityID: community.id, + relayOrigin: $0, + channelID: channelID + ) + } + }.flatMap { channel in + channel.relayMetadataPubkey == relayMetadataPubkey ? channel : nil + } + let timestamp = Int(now().timeIntervalSince1970) + let profileNeedsRefresh = Self.isStale( + cachedAt: cachedProfile?.cachedAt, + now: timestamp, + lifetime: presentationCacheLifetime + ) + let membershipNeedsRefresh: Bool = { + guard let cachedChannel else { return true } + if Self.isStale( + cachedAt: cachedChannel.membershipCachedAt, + now: timestamp, + lifetime: presentationCacheLifetime + ) { + return true + } + guard let memberCount = cachedChannel.memberCount else { return true } + guard memberCount <= BuzzPushPresentationCacheStore.maximumMembersPerChannel + else { return false } + return cachedChannel.memberDigests?.count != memberCount + }() + let channelNeedsRefresh = channelID != nil && ( + Self.isStale( + cachedAt: cachedChannel?.cachedAt, + now: timestamp, + lifetime: presentationCacheLifetime + ) || membershipNeedsRefresh + ) + let fallback = Self.makeResolution( + event: event, + community: community, + profile: cachedProfile, + channel: cachedChannel + ) + guard profileNeedsRefresh || channelNeedsRefresh else { + completion(fallback) + return + } + + refreshPresentation( + event: event, + community: community, + refreshProfile: profileNeedsRefresh, + refreshChannel: channelNeedsRefresh + ) { refreshedProfileEvent, refreshedChannelEvent, refreshedMembershipEvent in + let profile = refreshedProfileEvent.flatMap { + guard BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedProfile?.eventCreatedAt, + existingID: cachedProfile?.eventID, + candidateCreatedAt: $0.createdAt, + candidateID: $0.id + ) else { return nil } + return Self.ephemeralProfile( + event: $0, + communityID: community.id, + relayOrigin: relayOrigin ?? community.relayUrl, + cached: cachedProfile, + cachedAt: timestamp + ) + } ?? cachedProfile + let newerChannelEvent: VerifiedNostrEvent? = refreshedChannelEvent.flatMap { event in + guard BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedChannel?.eventCreatedAt, + existingID: cachedChannel?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) else { return nil } + return event + } + let newerMembershipEvent: VerifiedNostrEvent? = refreshedMembershipEvent.flatMap { event in + guard BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedChannel?.membershipEventCreatedAt, + existingID: cachedChannel?.membershipEventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) else { return nil } + return event + } + let channel = relayMetadataPubkey.flatMap { + Self.ephemeralChannel( + metadataEvent: newerChannelEvent, + membershipEvent: newerMembershipEvent, + cached: cachedChannel, + communityID: community.id, + relayOrigin: relayOrigin ?? community.relayUrl, + relayMetadataPubkey: $0, + cachedAt: timestamp + ) + } ?? cachedChannel + completion( + Self.makeResolution( + event: event, + community: community, + profile: profile, + channel: channel + ) ?? fallback + ) + } + } + + private func refreshPresentation( + event: VerifiedNostrEvent, + community: PushLeaseCommunity, + refreshProfile: Bool, + refreshChannel: Bool, + completion: @escaping ( + VerifiedNostrEvent?, VerifiedNostrEvent?, VerifiedNostrEvent? + ) -> Void + ) { + guard let privateKey = loadPrivateKey(community.id), + let relayURL = community.relayURL, + let url = URL(string: "/query", relativeTo: relayURL) + else { + completion(nil, nil, nil) + return + } + let channelID = Self.tagValue("h", in: event) + let relayMetadataPubkey = community.relayMetadataPubkey?.lowercased() + var filters: [[String: Any]] = [] + if refreshProfile { + filters.append(["kinds": [0], "authors": [event.pubkey.lowercased()], "limit": 1]) + } + if refreshChannel, let channelID, let relayMetadataPubkey { + filters.append([ + "kinds": [39_000], + "authors": [relayMetadataPubkey], + "#d": [channelID], + "limit": 1, + ]) + filters.append([ + "kinds": [39_002], + "authors": [relayMetadataPubkey], + "#d": [channelID], + "limit": 1, + ]) + } + guard !filters.isEmpty, + let body = try? JSONSerialization.data(withJSONObject: filters) + else { + completion(nil, nil, nil) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = 1 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, + method: "POST", + body: body, + privateKeyHex: privateKey + ) else { + completion(nil, nil, nil) + return + } + request.setValue(auth, forHTTPHeaderField: "Authorization") + session.downloadTask(with: request) { fileURL, response, _ in + guard let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode), + let fileURL, + let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), + let fileSize = values.fileSize, + fileSize <= Self.maximumPresentationResponseBytes, + let data = try? Data(contentsOf: fileURL), + let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + else { + completion(nil, nil, nil) + return + } + let verified = events.filter { $0.hasValidIDAndSignature() } + let profile = refreshProfile ? Self.newest(verified.filter { + $0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased() + }) : nil + let channel = refreshChannel ? channelID.flatMap { channelID in + Self.newest(verified.filter { + $0.kind == 39_000 + && $0.pubkey.lowercased() == relayMetadataPubkey + && Self.tagValue("d", in: $0) == channelID + }) + } : nil + let membership = refreshChannel ? channelID.flatMap { channelID in + Self.newest(verified.filter { + $0.kind == 39_002 + && $0.pubkey.lowercased() == relayMetadataPubkey + && Self.tagValue("d", in: $0) == channelID + }) + } : nil + completion(profile, channel, membership) + }.resume() + } + + static func decodeResolution( + events: [VerifiedNostrEvent], community: PushLeaseCommunity + ) -> (BuzzPushResolution, VerifiedNostrEvent)? { + let event = newestMessage(events: events, community: community) + guard let event else { return nil } + guard let resolution = makeResolution( + event: event, + community: community, + profile: nil, + channel: nil + ) else { return nil } + return (resolution, event) + } + + private static func newestMessage( + events: [VerifiedNostrEvent], + community: PushLeaseCommunity + ) -> VerifiedNostrEvent? { + guard let mine = community.pubkey?.lowercased() else { return nil } + return events.filter { + $0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind) + }.sorted { + $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt + }.first + } + + private static func makeResolution( + event: VerifiedNostrEvent, + community: PushLeaseCommunity, + profile: BuzzPushCachedProfile?, + channel: BuzzPushCachedChannel? + ) -> BuzzPushResolution? { + let body = previewBody(event.content) + guard !body.isEmpty else { return nil } + let channelID = tagValue("h", in: event) + let conversationIdentifier = channelID.map { + BuzzPushPresentationIdentity.conversation(communityID: community.id, channelID: $0) + } + let conversation = channel.flatMap { + communicationConversation(event: event, community: community, channel: $0) + } + return BuzzPushResolution( + title: profile?.displayName ?? shortPubkey(event.pubkey), + body: body, + subtitle: community.name, + threadIdentifier: conversationIdentifier ?? community.id, + navigationTarget: channelID.map { + BuzzPushNavigationTarget( + eventID: event.id, + communityID: community.id, + channelID: $0 + ) + }, + senderPubkey: event.pubkey.lowercased(), + senderAvatarPNG: profile?.avatarPNG, + conversationIdentifier: conversationIdentifier, + conversationDisplayName: conversation?.displayName, + conversationRecipientCount: conversation?.recipientCount + ) + } + + private static func ephemeralProfile( + event: VerifiedNostrEvent, + communityID: String, + relayOrigin: String, + cached: BuzzPushCachedProfile?, + cachedAt: Int + ) -> BuzzPushCachedProfile { + let metadata = BuzzPushPresentationCacheStore.profileMetadata(event) + return BuzzPushCachedProfile( + communityID: communityID, + relayOrigin: relayOrigin, + pubkey: event.pubkey.lowercased(), + displayName: metadata.displayName, + pictureHash: metadata.pictureHash, + avatarPNG: cached?.pictureHash == metadata.pictureHash ? cached?.avatarPNG : nil, + eventID: event.id, + eventCreatedAt: event.createdAt, + cachedAt: cachedAt + ) + } + + private static func ephemeralChannel( + metadataEvent: VerifiedNostrEvent?, + membershipEvent: VerifiedNostrEvent?, + cached: BuzzPushCachedChannel?, + communityID: String, + relayOrigin: String, + relayMetadataPubkey: String, + cachedAt: Int + ) -> BuzzPushCachedChannel? { + guard metadataEvent != nil || cached != nil else { return nil } + let channelID = metadataEvent.flatMap { tagValue("d", in: $0) } ?? cached?.channelID + guard let channelID, !channelID.isEmpty, + let eventID = metadataEvent?.id ?? cached?.eventID, + let eventCreatedAt = metadataEvent?.createdAt ?? cached?.eventCreatedAt, + let metadataCachedAt = metadataEvent == nil ? cached?.cachedAt : cachedAt + else { return nil } + let membership = membershipEvent.flatMap { + BuzzPushPresentationCacheStore.normalizedChannelMembership( + $0, + communityID: communityID, + channelID: channelID + ) + } + let acceptedMembershipEvent = membership == nil ? nil : membershipEvent + return BuzzPushCachedChannel( + communityID: communityID, + relayOrigin: relayOrigin, + channelID: channelID, + relayMetadataPubkey: relayMetadataPubkey, + displayName: metadataEvent.map { + BuzzPushPresentationCacheStore.normalizedDisplayName(tagValue("name", in: $0)) + } ?? cached?.displayName, + channelType: metadataEvent.map { + BuzzPushPresentationCacheStore.normalizedChannelType(tagValue("t", in: $0)) + } ?? cached?.channelType, + memberCount: membership?.count ?? cached?.memberCount, + memberDigests: membership?.digests ?? cached?.memberDigests, + membershipEventID: acceptedMembershipEvent?.id ?? cached?.membershipEventID, + membershipEventCreatedAt: acceptedMembershipEvent?.createdAt + ?? cached?.membershipEventCreatedAt, + membershipCachedAt: acceptedMembershipEvent == nil + ? cached?.membershipCachedAt : cachedAt, + eventID: eventID, + eventCreatedAt: eventCreatedAt, + cachedAt: metadataCachedAt + ) + } + + private static func communicationConversation( + event: VerifiedNostrEvent, + community: PushLeaseCommunity, + channel: BuzzPushCachedChannel + ) -> (displayName: String?, recipientCount: Int)? { + guard let currentUser = community.pubkey?.lowercased(), + let memberCount = channel.memberCount, + let memberDigests = channel.memberDigests, + memberDigests.count == memberCount + else { return nil } + let currentUserDigest = BuzzPushPresentationIdentity.channelMember( + communityID: community.id, + channelID: channel.channelID, + pubkey: currentUser + ) + guard memberDigests.contains(currentUserDigest) else { return nil } + let senderDigest = BuzzPushPresentationIdentity.channelMember( + communityID: community.id, + channelID: channel.channelID, + pubkey: event.pubkey + ) + let senderIsMember = memberDigests.contains(senderDigest) + let recipientCount = memberCount - (senderIsMember ? 1 : 0) + guard recipientCount > 0 else { return nil } + + if channel.channelType == "dm" { + guard memberCount == 2, senderIsMember else { return nil } + return (nil, recipientCount) + } + guard let channelType = channel.channelType, + ["stream", "forum"].contains(channelType), + let displayName = channel.displayName + else { return nil } + return (displayName, recipientCount) + } + + private static func newest(_ events: [VerifiedNostrEvent]) -> VerifiedNostrEvent? { + events.sorted { + $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt + }.first + } + + private static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? { + event.tags.first { $0.count >= 2 && $0[0] == name }?[1] + } + + private static func isStale( + cachedAt: Int?, + now: Int, + lifetime: TimeInterval + ) -> Bool { + guard let cachedAt else { return true } + return TimeInterval(max(0, now - cachedAt)) > lifetime + } + + static func previewBody(_ content: String) -> String { + var result = content.replacingOccurrences( + of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression) + result = result.replacingOccurrences(of: #"`([^`]*)`"#, with: "$1", options: .regularExpression) + result = result.replacingOccurrences( + of: #"!?\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression) + result = result.replacingOccurrences( + of: #"https?://\S+"#, with: "[link]", options: .regularExpression) + result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + return result.count > 180 + ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" : result + } + + static func shortPubkey(_ pubkey: String) -> String { + pubkey.count > 8 ? String(pubkey.prefix(8)) + "…" : pubkey + } + + private func loadCommunities() -> [PushLeaseCommunity] { + guard let data = loadCommunitiesData(), + let decoded = try? JSONDecoder().decode(PushLeaseSnapshot.self, from: data) + else { return [] } + return decoded.communities + } +} + +extension PushLeaseCommunity { + var relayURL: URL? { + guard var components = URLComponents(string: relayUrl), + components.host?.isEmpty == false, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { return nil } + components.scheme = + switch components.scheme?.lowercased() { + case "wss": "https" + case "ws": "http" + case "https": "https" + case "http": "http" + default: nil + } + components.path = "" + return components.url + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift new file mode 100644 index 00000000000..df0a7360d9f --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -0,0 +1,752 @@ +import CryptoKit +import Foundation + +/// A verified sender profile retained for notification presentation. +public struct BuzzPushCachedProfile: Codable, Equatable, Sendable { + public let communityID: String + public let relayOrigin: String + public let pubkey: String + public let displayName: String? + public let pictureHash: String? + public let avatarPNG: Data? + public let eventID: String + public let eventCreatedAt: Int + public let cachedAt: Int + + public init( + communityID: String, + relayOrigin: String, + pubkey: String, + displayName: String?, + pictureHash: String?, + avatarPNG: Data?, + eventID: String, + eventCreatedAt: Int, + cachedAt: Int + ) { + self.communityID = communityID + self.relayOrigin = relayOrigin + self.pubkey = pubkey + self.displayName = displayName + self.pictureHash = pictureHash + self.avatarPNG = avatarPNG + self.eventID = eventID + self.eventCreatedAt = eventCreatedAt + self.cachedAt = cachedAt + } +} + +/// Verified channel metadata retained for notification presentation. +public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { + public let communityID: String + public let relayOrigin: String + public let channelID: String + public let relayMetadataPubkey: String + public let displayName: String? + /// Relay-verified Buzz channel type, when recognized. + public let channelType: String? + /// Exact unique-member count when bounded, or a value above the bound when oversized. + public let memberCount: Int? + /// Complete community-and-channel-scoped member digests, when within bounds. + public let memberDigests: [String]? + /// Event ID that established the cached membership snapshot. + public let membershipEventID: String? + /// Creation time of the cached membership replacement event. + public let membershipEventCreatedAt: Int? + /// Device time when the membership snapshot was cached. + public let membershipCachedAt: Int? + public let eventID: String + public let eventCreatedAt: Int + public let cachedAt: Int + + public init( + communityID: String, + relayOrigin: String, + channelID: String, + relayMetadataPubkey: String, + displayName: String?, + channelType: String? = nil, + memberCount: Int? = nil, + memberDigests: [String]? = nil, + membershipEventID: String? = nil, + membershipEventCreatedAt: Int? = nil, + membershipCachedAt: Int? = nil, + eventID: String, + eventCreatedAt: Int, + cachedAt: Int + ) { + self.communityID = communityID + self.relayOrigin = relayOrigin + self.channelID = channelID + self.relayMetadataPubkey = relayMetadataPubkey + self.displayName = displayName + self.channelType = channelType + self.memberCount = memberCount + self.memberDigests = memberDigests + self.membershipEventID = membershipEventID + self.membershipEventCreatedAt = membershipEventCreatedAt + self.membershipCachedAt = membershipCachedAt + self.eventID = eventID + self.eventCreatedAt = eventCreatedAt + self.cachedAt = cachedAt + } +} + +/// Atomic App Group snapshot shared by the app and its notification extension. +public struct BuzzPushPresentationCacheSnapshot: Codable, Equatable, Sendable { + public static let currentVersion = 1 + + public let version: Int + public var profiles: [BuzzPushCachedProfile] + public var channels: [BuzzPushCachedChannel] + + public init( + version: Int = currentVersion, + profiles: [BuzzPushCachedProfile] = [], + channels: [BuzzPushCachedChannel] = [] + ) { + self.version = version + self.profiles = profiles + self.channels = channels + } + + public static func decode(_ data: Data?) -> Self { + guard let data, + let snapshot = try? JSONDecoder().decode(Self.self, from: data), + snapshot.version == currentVersion + else { return Self() } + return snapshot + } + + public func profile( + communityID: String, + relayOrigin: String, + pubkey: String + ) -> BuzzPushCachedProfile? { + let normalizedPubkey = pubkey.lowercased() + return profiles.first { + $0.communityID == communityID && $0.relayOrigin == relayOrigin + && $0.pubkey == normalizedPubkey + } + } + + public func channel( + communityID: String, + relayOrigin: String, + channelID: String + ) -> BuzzPushCachedChannel? { + channels.first { + $0.communityID == communityID && $0.relayOrigin == relayOrigin + && $0.channelID == channelID + } + } +} + +/// One app-provided profile update, optionally carrying a sanitized local thumbnail. +public struct BuzzPushProfileCacheUpdate: Sendable { + public let event: VerifiedNostrEvent + public let avatarPNG: Data? + + public init(event: VerifiedNostrEvent, avatarPNG: Data? = nil) { + self.event = event + self.avatarPNG = avatarPNG + } +} + +/// Maintains the bounded presentation snapshot. The app is the sole writer. +public final class BuzzPushPresentationCacheStore: @unchecked Sendable { + public static let fileName = "push-presentation-cache.json" + public static let freshnessLifetime: TimeInterval = 24 * 60 * 60 + public static let maximumProfiles = 256 + public static let maximumChannels = 512 + public static let maximumMembersPerChannel = 512 + public static let maximumTotalMemberDigests = 8_192 + public static let maximumAvatarBytes = 64 * 1024 + public static let maximumTotalAvatarBytes = 4 * 1024 * 1024 + public static let maximumSnapshotBytes = 8 * 1024 * 1024 + + private let fileURL: URL + private let now: () -> Date + private let lock = NSLock() + + public init(containerURL: URL, now: @escaping () -> Date = Date.init) { + fileURL = containerURL.appendingPathComponent(Self.fileName) + self.now = now + } + + /// Saves verified kind-0 events and returns the event IDs still needing thumbnails. + @discardableResult + public func updateProfiles( + communityID: String, + relayOrigin: String, + updates: [BuzzPushProfileCacheUpdate] + ) throws -> Set { + guard Self.isBoundedOpaqueID(communityID), + let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin), + updates.count <= Self.maximumProfiles + else { return [] } + lock.lock() + defer { lock.unlock() } + + var snapshot = loadLocked() + let cachedAt = Int(now().timeIntervalSince1970) + var acceptedEventIDs = Set() + for update in updates { + let event = update.event + guard event.kind == 0, event.hasValidIDAndSignature() else { continue } + let pubkey = event.pubkey.lowercased() + guard Self.isHexPubkey(pubkey) else { continue } + + let metadata = Self.profileMetadata(event) + let index = snapshot.profiles.firstIndex { + $0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin + && $0.pubkey == pubkey + } + let existing = index.map { snapshot.profiles[$0] } + guard Self.shouldReplace( + existingCreatedAt: existing?.eventCreatedAt, + existingID: existing?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) else { continue } + + let suppliedAvatar = Self.normalizedAvatarPNG(update.avatarPNG) + let preservedAvatar = existing?.pictureHash == metadata.pictureHash + ? existing?.avatarPNG : nil + let entry = BuzzPushCachedProfile( + communityID: communityID, + relayOrigin: canonicalRelayOrigin, + pubkey: pubkey, + displayName: metadata.displayName, + pictureHash: metadata.pictureHash, + avatarPNG: metadata.pictureHash == nil ? nil : suppliedAvatar ?? preservedAvatar, + eventID: event.id, + eventCreatedAt: event.createdAt, + cachedAt: cachedAt + ) + if let index { + snapshot.profiles[index] = entry + } else { + snapshot.profiles.append(entry) + } + acceptedEventIDs.insert(event.id) + } + + Self.enforceBounds(&snapshot) + try writeLocked(snapshot) + return Set(snapshot.profiles.compactMap { profile in + guard profile.communityID == communityID, + profile.relayOrigin == canonicalRelayOrigin, + acceptedEventIDs.contains(profile.eventID), + profile.pictureHash != nil, + profile.avatarPNG == nil + else { return nil } + return profile.eventID + }) + } + + /// Saves bounded relay-authorized kind-39000 metadata and kind-39002 membership snapshots. + public func updateChannels( + communityID: String, + relayOrigin: String, + relayMetadataPubkey: String, + metadataEvents: [VerifiedNostrEvent], + membershipEvents: [VerifiedNostrEvent] + ) throws { + let normalizedRelayPubkey = relayMetadataPubkey.lowercased() + guard Self.isBoundedOpaqueID(communityID), + let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin), + Self.isHexPubkey(normalizedRelayPubkey), + metadataEvents.count <= Self.maximumChannels, + membershipEvents.count <= Self.maximumChannels + else { + return + } + lock.lock() + defer { lock.unlock() } + + var snapshot = loadLocked() + let cachedAt = Int(now().timeIntervalSince1970) + for event in metadataEvents { + guard event.kind == 39_000, event.hasValidIDAndSignature(), + event.pubkey.lowercased() == normalizedRelayPubkey, + let channelID = Self.tagValue("d", in: event), + Self.isBoundedOpaqueID(channelID) + else { continue } + let index = snapshot.channels.firstIndex { + $0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin + && $0.channelID == channelID + } + let existing = index.map { snapshot.channels[$0] } + let hasCurrentAuthority = existing?.relayMetadataPubkey == normalizedRelayPubkey + guard !hasCurrentAuthority || Self.shouldReplace( + existingCreatedAt: existing?.eventCreatedAt, + existingID: existing?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) else { continue } + + let entry = BuzzPushCachedChannel( + communityID: communityID, + relayOrigin: canonicalRelayOrigin, + channelID: channelID, + relayMetadataPubkey: normalizedRelayPubkey, + displayName: Self.normalizedDisplayName(Self.tagValue("name", in: event)), + channelType: Self.normalizedChannelType(Self.tagValue("t", in: event)), + memberCount: hasCurrentAuthority ? existing?.memberCount : nil, + memberDigests: hasCurrentAuthority ? existing?.memberDigests : nil, + membershipEventID: hasCurrentAuthority ? existing?.membershipEventID : nil, + membershipEventCreatedAt: hasCurrentAuthority + ? existing?.membershipEventCreatedAt : nil, + membershipCachedAt: hasCurrentAuthority ? existing?.membershipCachedAt : nil, + eventID: event.id, + eventCreatedAt: event.createdAt, + cachedAt: cachedAt + ) + if let index { + snapshot.channels[index] = entry + } else { + snapshot.channels.append(entry) + } + } + Self.enforceChannelCountBound(&snapshot) + + for event in membershipEvents { + guard event.kind == 39_002, event.hasValidIDAndSignature(), + event.pubkey.lowercased() == normalizedRelayPubkey, + let channelID = Self.tagValue("d", in: event), + Self.isBoundedOpaqueID(channelID), + let membership = Self.normalizedChannelMembership( + event, + communityID: communityID, + channelID: channelID + ), + let index = snapshot.channels.firstIndex(where: { + $0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin + && $0.channelID == channelID + && $0.relayMetadataPubkey == normalizedRelayPubkey + }) + else { continue } + let existing = snapshot.channels[index] + guard Self.shouldReplace( + existingCreatedAt: existing.membershipEventCreatedAt, + existingID: existing.membershipEventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) else { continue } + + snapshot.channels[index] = BuzzPushCachedChannel( + communityID: existing.communityID, + relayOrigin: existing.relayOrigin, + channelID: existing.channelID, + relayMetadataPubkey: existing.relayMetadataPubkey, + displayName: existing.displayName, + channelType: existing.channelType, + memberCount: membership.count, + memberDigests: membership.digests, + membershipEventID: event.id, + membershipEventCreatedAt: event.createdAt, + membershipCachedAt: cachedAt, + eventID: existing.eventID, + eventCreatedAt: existing.eventCreatedAt, + cachedAt: existing.cachedAt + ) + Self.enforceMemberDigestBound(&snapshot) + } + + Self.enforceBounds(&snapshot) + try writeLocked(snapshot) + } + + /// Attaches an app-rendered thumbnail to every verified profile with this source digest. + @discardableResult + public func updateAvatar( + communityID: String, + relayOrigin: String, + sourceURL: String, + avatarPNG: Data + ) throws -> Bool { + guard Self.isBoundedOpaqueID(communityID), + let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin), + let normalizedURL = Self.normalizedAvatarURL(sourceURL), + let normalizedPNG = Self.normalizedAvatarPNG(avatarPNG) + else { return false } + let pictureHash = VerifiedNostrEvent.hex( + SHA256.hash(data: Data(normalizedURL.utf8)) + ) + lock.lock() + defer { lock.unlock() } + + var snapshot = loadLocked() + var changed = false + for index in snapshot.profiles.indices where + snapshot.profiles[index].communityID == communityID + && snapshot.profiles[index].relayOrigin == canonicalRelayOrigin + && snapshot.profiles[index].pictureHash == pictureHash + && snapshot.profiles[index].avatarPNG != normalizedPNG + { + let profile = snapshot.profiles[index] + snapshot.profiles[index] = BuzzPushCachedProfile( + communityID: profile.communityID, + relayOrigin: profile.relayOrigin, + pubkey: profile.pubkey, + displayName: profile.displayName, + pictureHash: profile.pictureHash, + avatarPNG: normalizedPNG, + eventID: profile.eventID, + eventCreatedAt: profile.eventCreatedAt, + cachedAt: profile.cachedAt + ) + changed = true + } + guard changed else { return false } + Self.enforceBounds(&snapshot) + try writeLocked(snapshot) + return true + } + + /// Removes metadata belonging to communities no longer present in the app. + public func retainCommunities(_ communityIDs: Set) throws { + lock.lock() + defer { lock.unlock() } + var snapshot = loadLocked() + snapshot.profiles.removeAll { !communityIDs.contains($0.communityID) } + snapshot.channels.removeAll { !communityIDs.contains($0.communityID) } + try writeLocked(snapshot) + } + + private func loadLocked() -> BuzzPushPresentationCacheSnapshot { + guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), + let fileSize = values.fileSize, + fileSize <= Self.maximumSnapshotBytes + else { return BuzzPushPresentationCacheSnapshot() } + return BuzzPushPresentationCacheSnapshot.decode(try? Data(contentsOf: fileURL)) + } + + private func writeLocked(_ snapshot: BuzzPushPresentationCacheSnapshot) throws { + let data = try Self.encodedBoundedSnapshot(snapshot) + try data.write(to: fileURL, options: [.atomic]) + #if os(iOS) + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL.path + ) + #endif + } + + static func encodedBoundedSnapshot( + _ snapshot: BuzzPushPresentationCacheSnapshot + ) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + var bounded = snapshot + Self.enforceBounds(&bounded) + var data = try encoder.encode(bounded) + + if data.count > Self.maximumSnapshotBytes { + var estimatedExcess = data.count - Self.maximumSnapshotBytes + 1_024 + for index in bounded.profiles.indices.reversed() { + guard let avatar = bounded.profiles[index].avatarPNG else { continue } + estimatedExcess -= min(estimatedExcess, 4 * ((avatar.count + 2) / 3)) + bounded.profiles[index] = Self.removingAvatar(from: bounded.profiles[index]) + if estimatedExcess == 0 { break } + } + data = try encoder.encode(bounded) + } + + if data.count > Self.maximumSnapshotBytes { + for index in bounded.channels.indices.reversed() { + guard bounded.channels[index].memberDigests != nil else { continue } + bounded.channels[index] = Self.removingMemberDigests(from: bounded.channels[index]) + data = try encoder.encode(bounded) + if data.count <= Self.maximumSnapshotBytes { break } + } + } + + while data.count > Self.maximumSnapshotBytes, + !bounded.profiles.isEmpty || !bounded.channels.isEmpty + { + let entryCount = bounded.profiles.count + bounded.channels.count + let ratio = Double(Self.maximumSnapshotBytes) / Double(data.count) + let targetCount = max(0, min(entryCount - 1, Int(Double(entryCount) * ratio * 0.95))) + Self.removeOldestEntries(entryCount - targetCount, from: &bounded) + data = try encoder.encode(bounded) + } + return data + } + + static func profileMetadata( + _ event: VerifiedNostrEvent + ) -> (displayName: String?, pictureHash: String?) { + guard event.content.utf8.count <= 32 * 1024, + let data = event.content.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return (nil, nil) } + let displayName = normalizedDisplayName(object["display_name"] as? String) + ?? normalizedDisplayName(object["name"] as? String) + let pictureHash = normalizedAvatarURL(object["picture"] as? String).map { + VerifiedNostrEvent.hex(SHA256.hash(data: Data($0.utf8))) + } + return (displayName, pictureHash) + } + + static func normalizedDisplayName(_ value: String?) -> String? { + guard let value else { return nil } + let collapsed = value.precomposedStringWithCanonicalMapping + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + guard !collapsed.isEmpty else { return nil } + var bounded = String(collapsed.prefix(128)) + while bounded.utf8.count > 512, !bounded.isEmpty { + bounded.removeLast() + } + return bounded.isEmpty ? nil : bounded + } + + static func normalizedChannelType(_ value: String?) -> String? { + guard let value, ["stream", "forum", "dm"].contains(value) else { return nil } + return value + } + + static func normalizedChannelMembership( + _ event: VerifiedNostrEvent, + communityID: String, + channelID: String + ) -> (count: Int, digests: [String]?)? { + var pubkeys = Set() + var exceededMemberBound = false + for tag in event.tags where tag.first == "p" { + guard tag.count >= 2 else { return nil } + let pubkey = tag[1].lowercased() + guard isHexPubkey(pubkey) else { return nil } + if !exceededMemberBound { + pubkeys.insert(pubkey) + exceededMemberBound = pubkeys.count > maximumMembersPerChannel + } + } + let digests = exceededMemberBound ? nil : pubkeys.map { + BuzzPushPresentationIdentity.channelMember( + communityID: communityID, + channelID: channelID, + pubkey: $0 + ) + }.sorted() + return ( + exceededMemberBound ? maximumMembersPerChannel + 1 : pubkeys.count, + digests + ) + } + + static func normalizedAvatarURL(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.utf8.count <= 2_048, + let components = URLComponents(string: trimmed), + components.user == nil, components.password == nil, + components.host?.isEmpty == false, + ["http", "https"].contains(components.scheme?.lowercased() ?? "") + else { return nil } + return components.url?.absoluteString + } + + public static func canonicalRelayOrigin(_ value: String) -> String? { + guard value.utf8.count <= 2_048, + var components = URLComponents(string: value), + components.host?.isEmpty == false, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { return nil } + switch components.scheme?.lowercased() { + case "wss": components.scheme = "https" + case "ws": components.scheme = "http" + case "https", "http": break + default: return nil + } + components.path = "" + guard let result = components.string, result.utf8.count <= 2_048 else { return nil } + return result + } + + static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? { + event.tags.first { $0.count >= 2 && $0[0] == name }?[1] + } + + private static func isBoundedOpaqueID(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 1_024 + } + + private static func isHexPubkey(_ value: String) -> Bool { + value.count == 64 && VerifiedNostrEvent.hexBytes(value)?.count == 32 + } + + private static func normalizedAvatarPNG(_ data: Data?) -> Data? { + guard let data, !data.isEmpty, data.count <= maximumAvatarBytes, + data.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + else { return nil } + return data + } + + static func shouldReplace( + existingCreatedAt: Int?, + existingID: String?, + candidateCreatedAt: Int, + candidateID: String + ) -> Bool { + guard let existingCreatedAt, let existingID else { return true } + return candidateCreatedAt > existingCreatedAt + || (candidateCreatedAt == existingCreatedAt && candidateID <= existingID) + } + + static func enforceBounds(_ snapshot: inout BuzzPushPresentationCacheSnapshot) { + snapshot.profiles = Array( + snapshot.profiles.sorted(by: profileNewestFirst).prefix(maximumProfiles) + ) + enforceChannelCountBound(&snapshot) + enforceMemberDigestBound(&snapshot) + + var avatarBytes = snapshot.profiles.reduce(0) { $0 + ($1.avatarPNG?.count ?? 0) } + guard avatarBytes > maximumTotalAvatarBytes else { return } + for index in snapshot.profiles.indices.reversed() { + guard let avatar = snapshot.profiles[index].avatarPNG else { continue } + avatarBytes -= avatar.count + let profile = snapshot.profiles[index] + snapshot.profiles[index] = removingAvatar(from: profile) + if avatarBytes <= maximumTotalAvatarBytes { break } + } + } + + private static func enforceChannelCountBound( + _ snapshot: inout BuzzPushPresentationCacheSnapshot + ) { + snapshot.channels = Array( + snapshot.channels.sorted(by: channelNewestFirst).prefix(maximumChannels) + ) + } + + private static func enforceMemberDigestBound( + _ snapshot: inout BuzzPushPresentationCacheSnapshot + ) { + snapshot.channels.sort(by: channelNewestFirst) + var memberDigestCount = snapshot.channels.reduce(0) { + $0 + ($1.memberDigests?.count ?? 0) + } + guard memberDigestCount > maximumTotalMemberDigests else { return } + for index in snapshot.channels.indices.reversed() { + guard let memberDigests = snapshot.channels[index].memberDigests else { continue } + memberDigestCount -= memberDigests.count + snapshot.channels[index] = removingMemberDigests(from: snapshot.channels[index]) + if memberDigestCount <= maximumTotalMemberDigests { break } + } + } + + private static func removingAvatar( + from profile: BuzzPushCachedProfile + ) -> BuzzPushCachedProfile { + BuzzPushCachedProfile( + communityID: profile.communityID, + relayOrigin: profile.relayOrigin, + pubkey: profile.pubkey, + displayName: profile.displayName, + pictureHash: profile.pictureHash, + avatarPNG: nil, + eventID: profile.eventID, + eventCreatedAt: profile.eventCreatedAt, + cachedAt: profile.cachedAt + ) + } + + private static func removingMemberDigests( + from channel: BuzzPushCachedChannel + ) -> BuzzPushCachedChannel { + BuzzPushCachedChannel( + communityID: channel.communityID, + relayOrigin: channel.relayOrigin, + channelID: channel.channelID, + relayMetadataPubkey: channel.relayMetadataPubkey, + displayName: channel.displayName, + channelType: channel.channelType, + memberCount: channel.memberCount, + memberDigests: nil, + membershipEventID: channel.membershipEventID, + membershipEventCreatedAt: channel.membershipEventCreatedAt, + membershipCachedAt: channel.membershipCachedAt, + eventID: channel.eventID, + eventCreatedAt: channel.eventCreatedAt, + cachedAt: channel.cachedAt + ) + } + + private static func removeOldestEntries( + _ count: Int, + from snapshot: inout BuzzPushPresentationCacheSnapshot + ) { + for _ in 0.. Bool { + lhs.cachedAt == rhs.cachedAt ? lhs.eventID > rhs.eventID : lhs.cachedAt > rhs.cachedAt + } + + private static func channelNewestFirst( + _ lhs: BuzzPushCachedChannel, + _ rhs: BuzzPushCachedChannel + ) -> Bool { + let lhsCachedAt = channelLastCachedAt(lhs) + let rhsCachedAt = channelLastCachedAt(rhs) + return lhsCachedAt == rhsCachedAt ? lhs.eventID > rhs.eventID : lhsCachedAt > rhsCachedAt + } + + private static func channelLastCachedAt(_ channel: BuzzPushCachedChannel) -> Int { + max(channel.cachedAt, channel.membershipCachedAt ?? 0) + } +} + +/// Stable, privacy-preserving identifiers used only after the NSE resolves an event. +public enum BuzzPushPresentationIdentity { + public static func conversation(communityID: String, channelID: String) -> String { + scoped(namespace: "conversation", values: [communityID, channelID]) + } + + public static func sender(communityID: String, pubkey: String) -> String { + scoped(namespace: "sender", values: [communityID, pubkey.lowercased()]) + } + + /// Returns a stable, channel-scoped digest used for exact local membership checks. + public static func channelMember( + communityID: String, + channelID: String, + pubkey: String + ) -> String { + scoped( + namespace: "channel-member", + values: [communityID, channelID, pubkey.lowercased()] + ) + } + + private static func scoped(namespace: String, values: [String]) -> String { + let encoded = (try? JSONEncoder().encode([namespace] + values)) ?? Data() + return "buzz.\(namespace).\(VerifiedNostrEvent.hex(SHA256.hash(data: encoded)))" + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift new file mode 100644 index 00000000000..fd0bc34d696 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift @@ -0,0 +1,227 @@ +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 + /// authority-bearing strings; rather than guess at UTF-8-vs-escaping + /// behavior we fail closed. + case nonASCIIInput(field: String) +} + +/// Canonical NIP-PL App Attest transcript encoder. +/// +/// NIP-PL ("Exact App Attest transcript construction") pins the exact bytes +/// every App Attest operation signs: +/// +/// + "\n" + +/// +/// The JSON object has no insignificant whitespace, members appear in a fixed +/// per-route order, integers use shortest decimal notation, and strings use +/// minimal JSON escaping (quotation mark, reverse solidus, U+0000..U+001F). +/// The gateway builds the same bytes with serde_json and compares hashes, so +/// any byte difference is a silent `401 invalid_attestation`. This encoder is +/// hand-rolled for that reason: `JSONSerialization` escapes `/` as `\/` and +/// does not guarantee member order, so it must never be used for transcripts. +/// +/// Ground truth: `crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json`, +/// generated and asserted by the gateway's own encoder. The tests in this +/// package replay those vectors byte-for-byte. +/// +/// The `audience` member of each transcript is a **fixed protocol constant** +/// defined by NIP-PL (`https://push.buzz.xyz/v1/...`). It is a cross-route +/// domain-separation string, not a deployment URL: the gateway hardcodes it +/// regardless of where it is hosted, so clients must never derive it from a +/// discovered gateway base URL or relay host. +public enum BuzzPushTranscript { + // MARK: Domains + + public static let enrollDomain = "buzz.push.enroll.v1" + public static let delegateDomain = "buzz.push.delegate.v1" + public static let rotateEndpointDomain = "buzz.push.rotate-endpoint.v1" + public static let revokeDelegationDomain = "buzz.push.revoke-delegation.v1" + public static let revokeInstallationDomain = "buzz.push.revoke-installation.v1" + + // MARK: Fixed audiences (protocol constants, see type docs) + + public static let enrollAudience = "https://push.buzz.xyz/v1/installations" + public static let delegateAudience = "https://push.buzz.xyz/v1/delegations" + public static let rotateEndpointAudience = "https://push.buzz.xyz/v1/installations/endpoint" + public static let revokeDelegationAudience = "https://push.buzz.xyz/v1/delegations/revoke" + public static let revokeInstallationAudience = "https://push.buzz.xyz/v1/installations/revoke" + + /// Wire version pinned by NIP-PL. Every transcript carries `"v":1`. + public static let wireVersion: Int64 = 1 + + // MARK: Transcripts + + /// `buzz.push.enroll.v1` — these exact bytes are the App Attest + /// `clientData` supplied to attestation verification. + public static func enroll( + challengeId: UUID, + challenge: String, + keyId: String, + appProfile: String, + endpoint: String, + endpointEpoch: Int64, + expiresAt: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.enrollAudience) + 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) + return encode(domain: enrollDomain, object: o) + } + + /// `buzz.push.delegate.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func delegate( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + endpointEpoch: Int64, + generation: Int64, + relayPubkey: String, + notBefore: Int64, + expiresAt: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.delegateAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + o.int("endpoint_epoch", endpointEpoch) + o.int("generation", generation) + try o.string("relay_pubkey", relayPubkey, field: "relay_pubkey") + o.int("not_before", notBefore) + o.int("expires_at", expiresAt) + return encode(domain: delegateDomain, object: o) + } + + /// `buzz.push.rotate-endpoint.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func rotateEndpoint( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + endpointEpoch: Int64, + newEndpointEpoch: Int64, + endpoint: String + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.rotateEndpointAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + o.int("endpoint_epoch", endpointEpoch) + o.int("new_endpoint_epoch", newEndpointEpoch) + try o.string("endpoint", endpoint, field: "endpoint") + return encode(domain: rotateEndpointDomain, object: o) + } + + /// `buzz.push.revoke-delegation.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func revokeDelegation( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + relayPubkey: String, + generation: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.revokeDelegationAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + try o.string("relay_pubkey", relayPubkey, field: "relay_pubkey") + o.int("generation", generation) + return encode(domain: revokeDelegationDomain, object: o) + } + + /// `buzz.push.revoke-installation.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func revokeInstallation( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + endpointEpoch: Int64, + newEndpointEpoch: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.revokeInstallationAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + o.int("endpoint_epoch", endpointEpoch) + o.int("new_endpoint_epoch", newEndpointEpoch) + return encode(domain: revokeInstallationDomain, object: o) + } + + // MARK: Internals + + private static func encode(domain: String, object: CanonicalObject) -> Data { + Data((domain + "\n" + object.encoded()).utf8) + } + + /// Ordered compact JSON object writer. Emission order == call order; + /// there is deliberately no sorting, no whitespace, and no `Encodable` + /// round-trip anywhere near these bytes. + struct CanonicalObject { + private var members: [String] = [] + + mutating func int(_ key: String, _ value: Int64) { + // Swift's Int64 description is shortest decimal notation, which + // is what the spec pins and what serde_json emits. + members.append("\"\(key)\":\(value)") + } + + mutating func uuid(_ key: String, _ value: UUID) { + // Canonical lowercase-hyphenated form, matching uuid::Uuid's + // serde serialization. Foundation's uuidString is uppercase. + members.append("\"\(key)\":\"\(value.uuidString.lowercased())\"") + } + + mutating func string(_ key: String, _ value: String, field: String? = nil) throws { + members.append("\"\(key)\":\"\(try Self.escape(value, field: field ?? key))\"") + } + + func encoded() -> String { + "{" + members.joined(separator: ",") + "}" + } + + /// Minimal JSON string escaping, byte-identical to serde_json: + /// `"` and `\` get two-character escapes; U+0008, U+0009, U+000A, + /// U+000C, U+000D get their short forms; the remaining C0 controls + /// get lowercase `\u00xx`. Nothing else is escaped (in particular + /// `/` is NOT escaped — the JSONSerialization behavior that makes it + /// unusable here). Non-ASCII input is rejected outright. + static func escape(_ s: String, field: String) throws -> String { + var out = String() + out.reserveCapacity(s.count) + for scalar in s.unicodeScalars { + switch scalar.value { + case 0x22: out += "\\\"" + case 0x5C: out += "\\\\" + case 0x08: out += "\\b" + case 0x09: out += "\\t" + case 0x0A: out += "\\n" + case 0x0C: out += "\\f" + case 0x0D: out += "\\r" + case 0x00...0x1F: out += String(format: "\\u%04x", scalar.value) + case 0x20...0x7E: out.unicodeScalars.append(scalar) + default: throw BuzzPushTranscriptError.nonASCIIInput(field: field) + } + } + return out + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift new file mode 100644 index 00000000000..39123704561 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift @@ -0,0 +1,143 @@ +import CryptoKit +import Foundation +import P256K + +public enum NostrHTTPAuthError: Error, Equatable { + case invalidHex + case signingFailed +} + +public struct VerifiedNostrEvent: Codable, Equatable, Sendable { + public let id: String + public let pubkey: String + public let createdAt: Int + public let kind: Int + public let tags: [[String]] + public let content: String + public let sig: String + + enum CodingKeys: String, CodingKey { + case id, pubkey, kind, tags, content, sig + case createdAt = "created_at" + } + + public init( + id: String, pubkey: String, createdAt: Int, kind: Int, + tags: [[String]], content: String, sig: String + ) { + self.id = id + self.pubkey = pubkey + self.createdAt = createdAt + self.kind = kind + self.tags = tags + self.content = content + self.sig = sig + } + + public func hasValidIDAndSignature() -> Bool { + guard let idBytes = Self.hexBytes(id), idBytes.count == 32, + let pubkeyBytes = Self.hexBytes(pubkey), pubkeyBytes.count == 32, + let signatureBytes = Self.hexBytes(sig), signatureBytes.count == 64, + let serialized = try? Self.canonicalSerialization( + pubkey: pubkey.lowercased(), createdAt: createdAt, kind: kind, + tags: tags, content: content + ) + else { return false } + let digest = Array(SHA256.hash(data: serialized)) + guard digest == idBytes, + let signature = try? P256K.Schnorr.SchnorrSignature( + dataRepresentation: Data(signatureBytes) + ) + else { return false } + var message = digest + let key = P256K.Schnorr.XonlyKey(dataRepresentation: pubkeyBytes) + return key.isValid(signature, for: &message) + } + + static func canonicalSerialization( + pubkey: String, createdAt: Int, kind: Int, tags: [[String]], content: String + ) throws -> Data { + try JSONSerialization.data( + withJSONObject: [0, pubkey, createdAt, kind, tags, content], + options: [.withoutEscapingSlashes] + ) + } + + static func hexBytes(_ value: String) -> [UInt8]? { + guard value.count.isMultiple(of: 2) else { return nil } + var result: [UInt8] = [] + result.reserveCapacity(value.count / 2) + var index = value.startIndex + while index < value.endIndex { + let end = value.index(index, offsetBy: 2) + guard let byte = UInt8(value[index..) -> String { + bytes.map { String(format: "%02x", $0) }.joined() + } +} + +public enum NostrHTTPAuth { + public static func authorizationHeader( + url: URL, + method: String, + body: Data, + privateKeyHex: String, + createdAt: Int = Int(Date().timeIntervalSince1970), + auxiliaryRandomness: [UInt8]? = nil + ) throws -> String { + guard let privateKeyBytes = VerifiedNostrEvent.hexBytes(privateKeyHex), + privateKeyBytes.count == 32 + else { throw NostrHTTPAuthError.invalidHex } + do { + let privateKey = try P256K.Schnorr.PrivateKey( + dataRepresentation: privateKeyBytes + ) + let pubkey = VerifiedNostrEvent.hex(privateKey.xonly.bytes) + let payload = VerifiedNostrEvent.hex(SHA256.hash(data: body)) + let tags = [ + ["u", url.absoluteString], + ["method", method.uppercased()], + ["payload", payload], + ] + let serialized = try VerifiedNostrEvent.canonicalSerialization( + pubkey: pubkey, createdAt: createdAt, kind: 27235, + tags: tags, content: "" + ) + let digest = Array(SHA256.hash(data: serialized)) + var message = digest + let signature: P256K.Schnorr.SchnorrSignature + if var randomness = auxiliaryRandomness { + guard randomness.count == 32 else { throw NostrHTTPAuthError.signingFailed } + signature = try privateKey.signature( + message: &message, auxiliaryRand: &randomness + ) + } else { + signature = try privateKey.signature( + message: &message, auxiliaryRand: nil + ) + } + let event = VerifiedNostrEvent( + id: VerifiedNostrEvent.hex(digest), + pubkey: pubkey, + createdAt: createdAt, + kind: 27235, + tags: tags, + content: "", + sig: VerifiedNostrEvent.hex(signature.dataRepresentation) + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + return "Nostr " + (try encoder.encode(event)).base64EncodedString() + } catch let error as NostrHTTPAuthError { + throw error + } catch { + throw NostrHTTPAuthError.signingFailed + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift new file mode 100644 index 00000000000..7ac75721f01 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift @@ -0,0 +1,201 @@ +import Foundation + +public enum PushLeaseError: Error, Equatable { + case unsupportedAuthority(String) + case acceptedAuthorityMissingSubscriptions + case emptySubscriptions +} + +public struct PushLeaseSnapshot: Codable, Equatable, Sendable { + public let communities: [PushLeaseCommunity] + + public init(communities: [PushLeaseCommunity]) { + self.communities = communities + } +} + +public struct PushLeaseCommunity: Codable, Equatable, Sendable { + public let id: String + public let name: String + public let relayUrl: String + /// Relay NIP-11 `self` key used to verify NIP-29 channel metadata. + public let relayMetadataPubkey: String? + public let pubkey: String? + public let pushSubscriptionState: PushLeaseSubscriptionState + + public init( + id: String, + name: String, + relayUrl: String, + relayMetadataPubkey: String? = nil, + pubkey: String?, + pushSubscriptionState: PushLeaseSubscriptionState + ) { + self.id = id + self.name = name + self.relayUrl = relayUrl + self.relayMetadataPubkey = relayMetadataPubkey + self.pubkey = pubkey + self.pushSubscriptionState = pushSubscriptionState + } +} + +public struct PushLeaseSubscriptionState: Codable, Equatable, Sendable { + public enum Authority: String, Codable, Sendable { + case desired + case accepted + } + + public let authority: String + public let desired: [PushLeaseSubscription] + public let accepted: [PushLeaseSubscription]? + + public init( + authority: String, + desired: [PushLeaseSubscription], + accepted: [PushLeaseSubscription]? = nil + ) { + self.authority = authority + self.desired = desired + self.accepted = accepted + } + + /// The app persists accepted authority only after the relay acknowledges the + /// corresponding lease. Until then the desired policy is used for snapshots. + public func authoritativeSubscriptions() throws -> [PushLeaseSubscription] { + let subscriptions: [PushLeaseSubscription] + switch authority { + case Authority.desired.rawValue: + subscriptions = desired + case Authority.accepted.rawValue: + guard let accepted else { + throw PushLeaseError.acceptedAuthorityMissingSubscriptions + } + subscriptions = accepted + default: + throw PushLeaseError.unsupportedAuthority(authority) + } + guard !subscriptions.isEmpty else { + throw PushLeaseError.emptySubscriptions + } + return subscriptions + } +} + +public struct PushLeaseSubscription: Codable, Equatable, Sendable { + public let filter: PushLeaseFilter + public let notificationClass: String + public let ignore: [PushLeaseFilter] + public let suppress: PushLeaseSuppression? + + enum CodingKeys: String, CodingKey { + case filter + case notificationClass = "class" + case ignore + case suppress + } + + public init( + filter: PushLeaseFilter, + notificationClass: String, + ignore: [PushLeaseFilter] = [], + suppress: PushLeaseSuppression? = nil + ) { + self.filter = filter + self.notificationClass = notificationClass + self.ignore = ignore + self.suppress = suppress + } +} + +public struct PushLeaseSuppression: Codable, Equatable, Sendable { + public let pTagsMax: Int + + enum CodingKeys: String, CodingKey { + case pTagsMax = "p_tags_max" + } + + public init(pTagsMax: Int) { + self.pTagsMax = pTagsMax + } +} + +public struct PushLeaseFilter: Codable, Equatable, Sendable { + public let kinds: [Int] + public let authors: [String]? + public let pTags: [String]? + public let hTags: [String]? + public let eTags: [String]? + + enum CodingKeys: String, CodingKey { + case kinds + case authors + case pTags = "#p" + case hTags = "#h" + case eTags = "#e" + } + + public init( + kinds: [Int], + authors: [String]? = nil, + pTags: [String]? = nil, + hTags: [String]? = nil, + eTags: [String]? = nil + ) { + self.kinds = kinds + self.authors = authors + self.pTags = pTags + self.hTags = hTags + self.eTags = eTags + } + + public func queryFilter(since: Int?, limit: Int) -> [String: Any] { + var filter: [String: Any] = ["kinds": kinds, "limit": limit] + if let authors { filter["authors"] = authors } + if let pTags { filter["#p"] = pTags } + if let hTags { filter["#h"] = hTags } + if let eTags { filter["#e"] = eTags } + if let since { filter["since"] = since } + return filter + } + + public func matches(_ event: VerifiedNostrEvent) -> Bool { + guard kinds.contains(event.kind) else { return false } + if let authors, !authors.contains(event.pubkey.lowercased()) { return false } + if let pTags, !event.hasAnyTag(named: "p", values: pTags) { return false } + if let hTags, !event.hasAnyTag(named: "h", values: hTags) { return false } + if let eTags, !event.hasAnyTag(named: "e", values: eTags) { return false } + return true + } +} + +public enum PushLeaseMatcher { + public static func matches( + event: VerifiedNostrEvent, + subscription: PushLeaseSubscription + ) -> Bool { + guard subscription.filter.matches(event) else { return false } + if subscription.ignore.contains(where: { $0.matches(event) }) { return false } + if let maximum = subscription.suppress?.pTagsMax, + event.tagCount(named: "p") > maximum + { + return false + } + return true + } +} + +extension VerifiedNostrEvent { + public func tagCount(named name: String) -> Int { + tags.reduce(into: 0) { count, tag in + if tag.count >= 2 && tag[0] == name { count += 1 } + } + } + + public func hasAnyTag(named name: String, values: [String]) -> Bool { + let expected = Set(values.map { $0.lowercased() }) + return tags.contains { tag in + tag.count >= 2 && tag[0] == name && expected.contains(tag[1].lowercased()) + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift new file mode 100644 index 00000000000..657c925e179 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift @@ -0,0 +1,29 @@ +import Foundation +import XCTest +@testable import BuzzPushKit + +final class APNsRegistrationBufferTests: XCTestCase { + func testReplaysTokenAfterChannelAttachment() { + let buffer = APNsRegistrationBuffer() + buffer.recordToken(Data([0x01, 0xAB, 0x00])) + var delivered: [APNsRegistrationUpdate] = [] + buffer.attach { delivered.append($0) } + XCTAssertEqual(delivered, [ + APNsRegistrationUpdate(method: "apnsTokenChanged", arguments: ["token": "01ab00"]) + ]) + XCTAssertNil(buffer.pending) + } + + func testKeepsLatestUpdateAndDeliversLiveFailures() { + let buffer = APNsRegistrationBuffer() + buffer.recordToken(Data([0x01])) + buffer.recordError("offline") + var delivered: [APNsRegistrationUpdate] = [] + buffer.attach { delivered.append($0) } + buffer.recordError("denied") + XCTAssertEqual(delivered, [ + APNsRegistrationUpdate(method: "apnsRegistrationFailed", arguments: ["message": "offline"]), + APNsRegistrationUpdate(method: "apnsRegistrationFailed", arguments: ["message": "denied"]), + ]) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift new file mode 100644 index 00000000000..821cd240024 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -0,0 +1,1146 @@ +import CryptoKit +import Foundation +import Security +import XCTest + +@testable import BuzzPushKit + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +final class BuzzDevPushEnrollmentDriverTests: XCTestCase { + private static let gatewayURL = URL(string: "http://push.example/")! + private static let relayURL = URL(string: "wss://relay.example/")! + private static let relayPubkey = String(repeating: "a", count: 64) + private static let firstChallengeId = "11111111-1111-4111-8111-111111111111" + private static let secondChallengeId = "33333333-3333-4333-8333-333333333333" + private static let installationHandle = "22222222-2222-4222-8222-222222222222" + private static let installationId = "000102030405060708090a0b0c0d0e0f" + private static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + private static let now: Int64 = 1_752_620_000 + private static let expiresAt: Int64 = 1_752_624_000 + private static let endpoint = + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + fileprivate static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=" + fileprivate static let attestation = Data("test-attestation".utf8).base64EncodedString() + fileprivate static let assertion = Data("buzz-dev-app-assertion-v1".utf8).base64EncodedString() + + override func setUp() { + super.setUp() + URLProtocolStub.reset() + } + + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + func testEnrollmentPinsTranscriptsAndPersistsOpaqueGrant() async throws { + let store = MemoryGrantStore() + let appAttest = RecordingAppAttest() + let driver = try makeDriver(store: store, appAttest: appAttest) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/nostr+json") + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": [ + "keys": [ + ["id": "current", "pubkey": Self.relayPubkey, "current": true] + ] + ] + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + let body = try Self.body(request) + XCTAssertEqual(body["v"] as? Int, 1) + XCTAssertEqual(body.count, 1) + let id = challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId + return Self.response( + request, + status: 200, + json: [ + "challenge_id": id, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + let body = try Self.body(request) + XCTAssertEqual(body["endpoint"] as? String, Self.endpoint) + XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) + XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) + XCTAssertEqual(body["challenge_id"] as? String, Self.firstChallengeId) + 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") + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["relay_pubkey"] as? String, Self.relayPubkey) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["challenge_id"] as? String, Self.secondChallengeId) + XCTAssertEqual(body["challenge"] as? String, Self.challenge) + XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) + XCTAssertEqual(body["not_before"] as? Int64, Self.now) + XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) + XCTAssertEqual(body["assertion"] as? String, Self.assertion) + XCTAssertEqual(body["generation"] as? Int, 1) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant"] + ) + default: + XCTFail( + "Unexpected request \(request.httpMethod ?? "nil") \(request.url?.absoluteString ?? "nil")" + ) + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(appAttest.clientData.count, 2) + XCTAssertEqual(record.relayOrigin, "wss://relay.example") + try assertMatchesVector( + "enroll", + actual: appAttest.clientData[0], + expectedSHA256: "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270", + fixture: makeFixtureTranscript(name: "enroll", replacements: []) + ) + try assertMatchesVector( + "delegate", + actual: appAttest.clientData[1], + expectedSHA256: "f186db11cb53e4e80f09489c11dd18afc9b641683c3d72a67113c57d32fca323", + fixture: makeFixtureTranscript( + name: "delegate", + replacements: [ + (Self.firstChallengeId, Self.secondChallengeId) + ] + ) + ) + XCTAssertEqual( + record, + BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + 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 + ) + ) + XCTAssertEqual(store.saved, [record]) + } + + func testRelayOriginPreservesNonDefaultPortWithoutTrailingSlash() async throws { + let relayURL = URL(string: "wss://relay.example:8443/")! + let store = MemoryGrantStore() + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example:8443/"): + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: relayURL + ) + + XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443") + } + + #if DEBUG + func testDevelopmentAttestationMatchesGatewayBypassShape() async throws { + let entropy = Data(repeating: 0xAB, count: 32) + let provider = BuzzDevAppAttestProvider(randomBytes: { entropy }) + let prepared = try await provider.prepareAttestation() + let bytes = try XCTUnwrap(Data(base64Encoded: prepared.attestation)) + XCTAssertEqual( + bytes, + Data("buzz-dev-app-attest-v1:".utf8) + entropy + ) + XCTAssertEqual( + prepared.keyId, + Data(SHA256.hash(data: bytes)).base64EncodedString() + ) + let assertion = try await provider.assertion(clientData: Data("transcript".utf8)) + XCTAssertEqual(assertion, Self.assertion) + } + #endif + + func testLegacyGrantDecodesWithoutMetadataAuthority() throws { + let data = Data( + #"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"#.utf8 + ) + + let record = try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data) + + XCTAssertEqual(record.relayPubkey, Self.relayPubkey) + XCTAssertNil(record.relayMetadataPubkey) + } + + func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { + let service = RecordingDCAppAttestService(isSupported: false) + let provider = BuzzDCAppAttestProvider( + service: service, + keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + ) + + do { + _ = try await provider.prepareAttestation() + XCTFail("Expected App Attest to be unavailable") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .appAttestUnsupported) + } + XCTAssertEqual(service.generateKeyCallCount, 0) + } + + func testRealAppAttestGeneratesPersistsAndMapsAttestation() async throws { + let service = RecordingDCAppAttestService( + generatedKeyId: Self.keyId, + attestationObject: Data([0x01, 0x02, 0x03]) + ) + let keyIdStore = MemoryAppAttestKeyIdStore() + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let clientData = Data("enrollment transcript".utf8) + + let prepared = try await provider.prepareAttestation() + let attestation = try await provider.attestation(prepared, clientData: clientData) + + XCTAssertEqual(prepared, BuzzDevAttestation(keyId: Self.keyId, attestation: "")) + XCTAssertEqual(keyIdStore.savedKeyIds, [Self.keyId]) + XCTAssertEqual(attestation.keyId, Self.keyId) + XCTAssertEqual(attestation.attestation, Data([0x01, 0x02, 0x03]).base64EncodedString()) + XCTAssertEqual(service.attestedKeyIds, [Self.keyId]) + XCTAssertEqual( + service.attestationClientDataHashes, + [Data(SHA256.hash(data: clientData))] + ) + } + + func testRealAppAttestAssertionReusesStoredKeyAndMapsObject() async throws { + let service = RecordingDCAppAttestService(assertionObject: Data([0x04, 0x05, 0x06])) + let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId) + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let clientData = Data("delegation transcript".utf8) + + let assertion = try await provider.assertion(clientData: clientData) + + XCTAssertEqual(assertion, Data([0x04, 0x05, 0x06]).base64EncodedString()) + XCTAssertEqual(service.assertedKeyIds, [Self.keyId]) + XCTAssertEqual( + service.assertionClientDataHashes, + [Data(SHA256.hash(data: clientData))] + ) + XCTAssertEqual(service.generateKeyCallCount, 0) + } + + func testRealAppAttestRejectsInvalidGeneratedKeyBeforePersistence() async throws { + for invalidKeyId in [ + "not-a-key-id", + String(Self.keyId.dropLast(2)) + "p=", + Data(repeating: 0xAA, count: 31).base64EncodedString(), + Data(repeating: 0xAA, count: 33).base64EncodedString(), + ] { + let service = RecordingDCAppAttestService(generatedKeyId: invalidKeyId) + let keyIdStore = MemoryAppAttestKeyIdStore() + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + + do { + _ = try await provider.prepareAttestation() + XCTFail("Accepted invalid generated key ID: \(invalidKeyId)") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + XCTAssertTrue(keyIdStore.savedKeyIds.isEmpty) + } + } + + func testRealAppAttestRejectsMismatchedPreparedKey() async throws { + let service = RecordingDCAppAttestService() + let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId) + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let otherKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString() + + do { + _ = try await provider.attestation( + BuzzDevAttestation(keyId: otherKeyId, attestation: ""), + clientData: Data("enrollment transcript".utf8) + ) + XCTFail("Expected the prepared key ID to match persistent state") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + XCTAssertTrue(service.attestedKeyIds.isEmpty) + } + + func testRealAppAttestForwardsServiceErrors() async throws { + let expected = NSError(domain: "DeviceCheckTest", code: 41) + let service = RecordingDCAppAttestService(error: expected) + let provider = BuzzDCAppAttestProvider( + service: service, + keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + ) + + do { + _ = try await provider.assertion(clientData: Data("delegation transcript".utf8)) + XCTFail("Expected the DeviceCheck error") + } catch { + XCTAssertEqual((error as NSError).domain, expected.domain) + XCTAssertEqual((error as NSError).code, expected.code) + } + } + + func testKeychainStoreReadsKeyIdAndIncludesAccessGroup() throws { + var capturedQuery: [String: Any] = [:] + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: "group.buzz", + copyMatching: { query, result in + capturedQuery = query as! [String: Any] + result?.pointee = Data(Self.keyId.utf8) as CFData + return errSecSuccess + } + ) + + XCTAssertEqual(try store.keyId(), Self.keyId) + XCTAssertEqual( + capturedQuery[kSecClass as String] as? String, kSecClassGenericPassword as String) + XCTAssertEqual(capturedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest") + XCTAssertEqual(capturedQuery[kSecAttrAccount as String] as? String, "key-id-v1") + XCTAssertEqual(capturedQuery[kSecAttrAccessGroup as String] as? String, "group.buzz") + XCTAssertEqual(capturedQuery[kSecReturnData as String] as? Bool, true) + XCTAssertEqual(capturedQuery[kSecMatchLimit as String] as? String, kSecMatchLimitOne as String) + } + + func testKeychainStoreReturnsNilOnMissAndRejectsInvalidData() throws { + let missing = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, _ in errSecItemNotFound } + ) + XCTAssertNil(try missing.keyId()) + + for invalidKeyId in [ + "bad", + String(Self.keyId.dropLast(2)) + "p=", + Data(repeating: 0xAA, count: 31).base64EncodedString(), + Data(repeating: 0xAA, count: 33).base64EncodedString(), + ] { + let invalid = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, result in + result?.pointee = Data(invalidKeyId.utf8) as CFData + return errSecSuccess + } + ) + XCTAssertThrowsError(try invalid.keyId(), "Accepted invalid key ID: \(invalidKeyId)") { + XCTAssertEqual($0 as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + } + } + + func testKeychainStoreUpdatesExistingKeyId() throws { + var updatedQuery: [String: Any] = [:] + var updatedValues: [String: Any] = [:] + var addCallCount = 0 + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { query, values in + updatedQuery = query as! [String: Any] + updatedValues = values as! [String: Any] + return errSecSuccess + }, + add: { _, _ in + addCallCount += 1 + return errSecSuccess + } + ) + + try store.saveKeyId(Self.keyId) + + XCTAssertEqual(updatedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest") + XCTAssertEqual(updatedValues[kSecValueData as String] as? Data, Data(Self.keyId.utf8)) + XCTAssertEqual(addCallCount, 0) + } + + func testKeychainStoreAddsMissingKeyIdWithDeviceOnlyAccessibility() throws { + var addedItem: [String: Any] = [:] + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: "group.buzz", + update: { _, _ in errSecItemNotFound }, + add: { item, _ in + addedItem = item as! [String: Any] + return errSecSuccess + } + ) + + try store.saveKeyId(Self.keyId) + + XCTAssertEqual(addedItem[kSecValueData as String] as? Data, Data(Self.keyId.utf8)) + XCTAssertEqual( + addedItem[kSecAttrAccessible as String] as? String, + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String + ) + XCTAssertEqual(addedItem[kSecAttrAccessGroup as String] as? String, "group.buzz") + } + + func testKeychainStoreSurfacesReadUpdateAndAddErrors() throws { + let readFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, _ in errSecInteractionNotAllowed } + ) + XCTAssertThrowsError(try readFailure.keyId()) { + XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed)) + } + + let updateFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { _, _ in errSecInteractionNotAllowed } + ) + XCTAssertThrowsError(try updateFailure.saveKeyId(Self.keyId)) { + XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed)) + } + + let addFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { _, _ in errSecItemNotFound }, + add: { _, _ in errSecDuplicateItem } + ) + XCTAssertThrowsError(try addFailure.saveKeyId(Self.keyId)) { + XCTAssertEqual(($0 as NSError).code, Int(errSecDuplicateItem)) + } + } + + func testReusesPersistedUnexpiredGrant() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + guard request.httpMethod == "GET" else { + XCTFail("Persisted grant reuse must not call the gateway") + return Self.response(request, status: 500, json: [:]) + } + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(record, existing) + XCTAssertEqual(store.saved, [existing]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testSecondOriginOnSameRelayKeyReusesGrantWithFreshLeaseAddress() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://first.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + 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 + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.absoluteString, "https://second.example/") + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: URL(string: "wss://second.example/")! + ) + + XCTAssertEqual(record.relayOrigin, "wss://second.example") + XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle) + XCTAssertEqual(record.endpointGrant, existing.endpointGrant) + XCTAssertEqual(record.generation, existing.generation) + XCTAssertEqual(record.installationId, Self.installationId) + XCTAssertNotEqual(record.installationId, existing.installationId) + XCTAssertEqual(store.saved.count, 2) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testSecondRelayKeyReusesAttestedInstallationAndCreatesOnlyDelegation() async throws { + let secondRelayPubkey = String(repeating: "b", count: 64) + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://first.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + 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 + ) + let store = MemoryGrantStore(records: [existing]) + let appAttest = RecordingAppAttest() + let driver = try makeDriver(store: store, appAttest: appAttest) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://second.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": secondRelayPubkey, + "push": ["keys": [["pubkey": secondRelayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.firstChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["relay_pubkey"] as? String, secondRelayPubkey) + XCTAssertEqual(body["generation"] as? Int, 1) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "second-relay-grant"] + ) + case ("POST", "http://push.example/v1/installations"): + XCTFail("A second relay must not create a duplicate APNs installation") + return Self.response(request, status: 500, json: [:]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: URL(string: "wss://second.example/")! + ) + + XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle) + XCTAssertEqual(record.relayPubkey, secondRelayPubkey) + XCTAssertEqual(record.endpointGrant, "second-relay-grant") + XCTAssertEqual(record.generation, 1) + XCTAssertEqual(appAttest.clientData.count, 1) + XCTAssertEqual(store.saved.count, 2) + } + + func testExpiredGrantReenrollsButReusesRelayLeaseAddress() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 7, + expiresAt: Self.now + 300 + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver( + store: store, + appAttest: RecordingAppAttest(), + installationIdBytes: { + XCTFail("Grant refresh must reuse the persisted installation id") + return Data(repeating: 0xFF, count: 16) + } + ) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["generation"] as? Int, 1) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "refreshed-grant"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(record.installationId, Self.installationId) + XCTAssertEqual(record.generation, 1) + XCTAssertEqual(record.endpointGrant, "refreshed-grant") + } + + func testRejectsMultipleCurrentRelayKeysBeforeGatewayEnrollment() async throws { + let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": [ + "keys": [ + ["pubkey": Self.relayPubkey, "current": true], + ["pubkey": String(repeating: "b", count: 64), "current": true], + ] + ] + ] + ) + } + + do { + _ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL) + XCTFail("Expected an invalid relay descriptor") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidRelayDescriptor) + } + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testTracksRelayMetadataAuthoritySeparatelyFromPushDelegationKey() async throws { + let pushPubkey = String(repeating: "b", count: 64) + let oldMetadataPubkey = String(repeating: "c", count: 64) + let deviceToken = Data((1...32).map(UInt8.init)) + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: pushPubkey, + relayMetadataPubkey: oldMetadataPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: deviceToken)), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": [ + "keys": [ + ["pubkey": pushPubkey, "current": true] + ] + ], + ] + ) + } + + let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL) + + XCTAssertEqual(record.relayPubkey, pushPubkey) + XCTAssertEqual(record.relayMetadataPubkey, Self.relayPubkey) + XCTAssertNotEqual(record.relayMetadataPubkey, oldMetadataPubkey) + XCTAssertEqual(record.endpointGrant, existing.endpointGrant) + XCTAssertEqual(store.saved, [record]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testMissingRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { + let deviceToken = Data((1...32).map(UInt8.init)) + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: deviceToken)), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "push": [ + "keys": [["pubkey": Self.relayPubkey, "current": true]] + ] + ] + ) + } + + let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL) + + XCTAssertEqual(record.relayPubkey, Self.relayPubkey) + XCTAssertNil(record.relayMetadataPubkey) + XCTAssertEqual(record.endpointGrant, existing.endpointGrant) + XCTAssertEqual(store.saved, [record]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testMalformedRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { + let deviceToken = Data((1...32).map(UInt8.init)) + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: deviceToken)), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "self": 42, + "push": [ + "keys": [["pubkey": Self.relayPubkey, "current": true]] + ], + ] + ) + } + + let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL) + + XCTAssertEqual(record.relayPubkey, Self.relayPubkey) + XCTAssertNil(record.relayMetadataPubkey) + XCTAssertEqual(record.endpointGrant, existing.endpointGrant) + XCTAssertEqual(store.saved, [record]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testFailsLoudlyOnUnexpectedGatewayStatus() async throws { + let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + if request.httpMethod == "GET" { + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + } + return Self.response(request, status: 400, json: ["error": "invalid_request"]) + } + + do { + _ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL) + XCTFail("Expected the gateway error") + } catch let error as BuzzDevPushEnrollmentError { + XCTAssertEqual( + error, + .unexpectedStatus( + route: "v1/installations/challenges", + expected: 200, + actual: 400, + body: "{\"error\":\"invalid_request\"}" + ) + ) + } + } + + private func makeDriver( + store: BuzzPushEndpointGrantStore, + appAttest: BuzzDevAppAttesting, + installationIdBytes: @escaping () throws -> Data = { + Data(0..<16) + } + ) throws -> BuzzDevPushEnrollmentDriver { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + return try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: Self.gatewayURL, + store: store, + session: URLSession(configuration: configuration), + appAttest: appAttest, + now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) }, + lifetimeSeconds: Self.expiresAt - Self.now, + installationIdBytes: installationIdBytes + ) + } + + private func makeFixtureTranscript( + name: String, + replacements: [(String, String)] + ) throws -> (bytes: Data, sha256: String) { + let fixture = try Self.fixture() + let vector = try XCTUnwrap(fixture.vectors.first { $0.name == name }) + let transcript = replacements.reduce(vector.transcript) { + $0.replacingOccurrences(of: $1.0, with: $1.1) + } + return (Data(transcript.utf8), Self.hex(SHA256.hash(data: Data(transcript.utf8)))) + } + + private func assertMatchesVector( + _ name: String, + actual: Data, + expectedSHA256: String, + fixture: (bytes: Data, sha256: String), + file: StaticString = #filePath, + line: UInt = #line + ) throws { + XCTAssertEqual( + fixture.sha256, + expectedSHA256, + "\(name) substituted gateway vector SHA-256", + file: file, + line: line + ) + XCTAssertEqual( + actual, fixture.bytes, "\(name) exact transcript bytes", file: file, line: line) + XCTAssertEqual( + Self.hex(SHA256.hash(data: actual)), + fixture.sha256, + "\(name) transcript SHA-256", + file: file, + line: line + ) + } + + private struct Fixture: Decodable { + struct Vector: Decodable { + let name: String + let transcript: String + } + let vectors: [Vector] + } + + private static func fixture() throws -> Fixture { + let path = try XCTUnwrap( + Bundle.module.url( + forResource: "app_attest_transcripts", + withExtension: "json" + ), + "missing bundled gateway transcript fixture app_attest_transcripts.json in \(Bundle.module.bundleURL.path)" + ) + let data = try Data(contentsOf: path) + return try JSONDecoder().decode(Fixture.self, from: data) + } + + private static func body(_ request: URLRequest) throws -> [String: Any] { + let data: Data + if let httpBody = request.httpBody { + data = httpBody + } else { + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + var bytes = Data() + var buffer = [UInt8](repeating: 0, count: 1_024) + while true { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { + throw try XCTUnwrap(stream.streamError) + } + if count == 0 { break } + bytes.append(buffer, count: count) + } + data = bytes + } + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private static func response( + _ request: URLRequest, + status: Int, + json: [String: Any] + ) -> (HTTPURLResponse, Data) { + let data = try! JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, data) + } + + private static func hex(_ data: D) -> String where D.Element == UInt8 { + data.map { String(format: "%02x", $0) }.joined() + } +} + +private final class MemoryGrantStore: BuzzPushEndpointGrantStore { + var saved: [BuzzPushEndpointGrantRecord] + init(records: [BuzzPushEndpointGrantRecord] = []) { saved = records } + func records() throws -> [BuzzPushEndpointGrantRecord] { saved } + func save(_ record: BuzzPushEndpointGrantRecord) throws { + saved.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + saved.append(record) + } +} + +private final class RecordingAppAttest: BuzzDevAppAttesting { + var clientData: [Data] = [] + + func prepareAttestation() async throws -> BuzzDevAttestation { + BuzzDevAttestation( + keyId: BuzzDevPushEnrollmentDriverTests.keyId, + attestation: BuzzDevPushEnrollmentDriverTests.attestation + ) + } + + func attestation( + _ prepared: BuzzDevAttestation, + clientData: Data + ) async throws -> BuzzDevAttestation { + self.clientData.append(clientData) + return prepared + } + + func assertion(clientData: Data) async throws -> String { + self.clientData.append(clientData) + return BuzzDevPushEnrollmentDriverTests.assertion + } +} + +private final class MemoryAppAttestKeyIdStore: BuzzAppAttestKeyIdStoring { + var keyIdValue: String? + var savedKeyIds: [String] = [] + + init(keyId: String? = nil) { + keyIdValue = keyId + } + + func keyId() throws -> String? { keyIdValue } + + func saveKeyId(_ keyId: String) throws { + savedKeyIds.append(keyId) + keyIdValue = keyId + } +} + +private final class RecordingDCAppAttestService: BuzzDCAppAttestServicing { + let isSupported: Bool + let generatedKeyId: String + let attestationObject: Data + let assertionObject: Data + let error: Error? + + var generateKeyCallCount = 0 + var attestedKeyIds: [String] = [] + var attestationClientDataHashes: [Data] = [] + var assertedKeyIds: [String] = [] + var assertionClientDataHashes: [Data] = [] + + init( + isSupported: Bool = true, + generatedKeyId: String = BuzzDevPushEnrollmentDriverTests.keyId, + attestationObject: Data = Data("attestation-object".utf8), + assertionObject: Data = Data("assertion-object".utf8), + error: Error? = nil + ) { + self.isSupported = isSupported + self.generatedKeyId = generatedKeyId + self.attestationObject = attestationObject + self.assertionObject = assertionObject + self.error = error + } + + func generateKey() async throws -> String { + generateKeyCallCount += 1 + if let error { throw error } + return generatedKeyId + } + + func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data { + attestedKeyIds.append(keyId) + attestationClientDataHashes.append(clientDataHash) + if let error { throw error } + return attestationObject + } + + func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data { + assertedKeyIds.append(keyId) + assertionClientDataHashes.append(clientDataHash) + if let error { throw error } + return assertionObject + } +} + +private final class URLProtocolStub: URLProtocol, @unchecked Sendable { + static let lock = NSLock() + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var requests: [URLRequest] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + Self.requests.append(request) + let handler = Self.handler + Self.lock.unlock() + do { + let (response, data) = + try handler?(request) + ?? { + throw URLError(.unsupportedURL) + }() + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + static func reset() { + lock.lock() + handler = nil + requests = [] + lock.unlock() + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift new file mode 100644 index 00000000000..9bf3b85f50b --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift @@ -0,0 +1,266 @@ +import Foundation +import XCTest + +@testable import BuzzPushKit + +extension BuzzPushNotificationResolverTests { + func testOpenChannelOutsiderUsesEveryVerifiedMemberAsARecipient() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Hello from an open-channel guest" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, relayPubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.conversationDisplayName, "General") + XCTAssertEqual(result.conversationRecipientCount, 2) + } + + func testMissingCurrentUserInVerifiedRosterUsesOrdinaryPresentation() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Do not fabricate a group" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "General", + channelType: "stream", + memberCount: 1, + memberDigests: Self.memberDigests([message.pubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertNil(result.conversationDisplayName) + XCTAssertNil(result.conversationRecipientCount) + XCTAssertEqual(result.subtitle, "Community") + } + + func testFreshEvictedMembershipDigestsTriggerOneBoundedRefresh() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Refresh an evicted roster" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "General", + channelType: "stream", + memberCount: 2, + memberDigests: nil, + membershipEventID: "evicted-membership", + membershipEventCreatedAt: Self.now - 1, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + let refreshedMembership = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now, + kind: 39_002, + tags: [ + ["d", Self.channelID], + ["p", Self.ownPubkey], + ["p", message.pubkey], + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + XCTAssertEqual(request.timeoutInterval, 1) + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([refreshedMembership]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.conversationDisplayName, "General") + XCTAssertEqual(result.conversationRecipientCount, 1) + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func testTwoPersonDMUsesDirectCommunicationPresentation() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Direct message" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "DM", + channelType: "dm", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertNil(result.conversationDisplayName) + XCTAssertEqual(result.conversationRecipientCount, 1) + } + + func testGroupDMWithoutVerifiedDisplayLabelUsesOrdinaryPresentation() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Group direct message" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "DM", + channelType: "dm", + memberCount: 3, + memberDigests: Self.memberDigests([ + Self.ownPubkey, + message.pubkey, + relayPubkey, + ]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertNil(result.conversationDisplayName) + XCTAssertNil(result.conversationRecipientCount) + XCTAssertEqual(result.subtitle, "Community") + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift new file mode 100644 index 00000000000..414e9446a2e --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing + +@testable import BuzzPushKit + +@Test func `Round-trip opaque navigation target through notification user info`() { + let target = BuzzPushNavigationTarget( + eventID: "MESSAGE-ID", + communityID: "community-id", + channelID: "CHANNEL/GENERAL" + ) + + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue] + ) == target + ) + #expect(target.eventID == "MESSAGE-ID") + #expect(target.channelID == "CHANNEL/GENERAL") +} + +@Test func `Reject incomplete or malformed navigation target`() { + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [ + BuzzPushNavigationTarget.userInfoKey: [ + "event_id": "message-id", + "community_id": "community-id", + ] + ] + ) == nil + ) + + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [ + BuzzPushNavigationTarget.userInfoKey: [ + "event_id": "", + "community_id": "community-id", + "channel_id": "channel-id", + ] + ] + ) == nil + ) + + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [ + BuzzPushNavigationTarget.userInfoKey: [ + "event_id": "message-id", + "community_id": "community-id", + "channel_id": "", + ] + ] + ) == nil + ) +} + +@Test func `Buffer preserves cold-start target until consumed`() { + let first = BuzzPushNavigationTarget( + eventID: String(repeating: "a", count: 64), + communityID: "community-id", + channelID: "123e4567-e89b-42d3-a456-426614174000" + ) + let second = BuzzPushNavigationTarget( + eventID: String(repeating: "b", count: 64), + communityID: "community-id", + channelID: "123e4567-e89b-42d3-a456-426614174000" + ) + let buffer = BuzzPushNavigationBuffer() + + buffer.record(first) + buffer.remove(ifMatching: second) + #expect(buffer.peek() == first) + #expect(buffer.take() == first) + #expect(buffer.take() == nil) +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift new file mode 100644 index 00000000000..c91ef665e49 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -0,0 +1,804 @@ +import CryptoKit +import Foundation +import P256K +import XCTest + +@testable import BuzzPushKit + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +final class BuzzPushNotificationResolverTests: XCTestCase { + static let privateKey = String(repeating: "0", count: 63) + "1" + static let ownPubkey = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + static let now = Int(Date().timeIntervalSince1970) + static let profilePrivateKey = String(repeating: "0", count: 63) + "2" + static let relayPrivateKey = String(repeating: "0", count: 63) + "3" + static let gatewayBody = "Reconnect to your relay now" + static let channelID = "123e4567-e89b-42d3-a456-426614174000" + + override func setUp() { + super.setUp() + URLProtocolStub.reset() + } + + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + func testResolveReturnsNilWhenCommunitiesDataIsMissing() { + let result = resolve(makeResolver(communitiesData: nil)) + + XCTAssertNil(result) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testResolveReturnsNilWhenCommunitiesDataIsUndecodable() { + let result = resolve(makeResolver(communitiesData: Data("not json".utf8))) + + XCTAssertNil(result) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testResolveReturnsNilOnKeychainMiss() throws { + let result = resolve( + makeResolver( + communitiesData: try snapshotData([community()]), + privateKeys: [:] + )) + + XCTAssertNil(result) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testResolveReturnsNilForNon2xxRelayResponse() throws { + URLProtocolStub.handler = { request in + Self.response(request, status: 503, data: Data()) + } + let result = resolve(makeResolver(communitiesData: try snapshotData([community()]))) + + XCTAssertNil(result) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testResolveReturnsNilForUndecodableRelayResponse() throws { + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: Data("not events".utf8)) + } + let result = resolve(makeResolver(communitiesData: try snapshotData([community()]))) + + XCTAssertNil(result) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testDecodeResolutionFiltersOwnPubkeyEvent() { + let result = BuzzPushNotificationResolver.decodeResolution( + events: [event(pubkey: Self.ownPubkey, content: "This should be filtered")], + community: community() + ) + + XCTAssertNil(result) + } + + func testDecodeResolutionReturnsNilWhenSanitizedPreviewIsEmpty() { + let event = event(content: " \n\t ") + + let result = BuzzPushNotificationResolver.decodeResolution( + events: [event], + community: community() + ) + + XCTAssertNil(result) + } + + func testPreviewBodySanitizesCodeLinksAndWhitespace() { + let content = """ + Before ```swift + print("secret") + ``` `inline` [docs](https://example.com/docs) + ![image](https://example.com/image.png) https://example.com/raw + After + """ + + XCTAssertEqual( + BuzzPushNotificationResolver.previewBody(content), + "Before [code] inline docs image [link] After" + ) + } + + func testPreviewBodyTruncatesTo178CharactersIncludingEllipsis() { + let preview = BuzzPushNotificationResolver.previewBody(String(repeating: "x", count: 200)) + + XCTAssertEqual(preview.count, 178) + XCTAssertEqual(preview, String(repeating: "x", count: 177) + "…") + } + + func testDecodeResolutionUsesLowestIDWhenCreatedAtTies() { + let result = BuzzPushNotificationResolver.decodeResolution( + events: [ + event(id: "a", content: "lower ID", createdAt: Self.now), + event(id: "b", content: "higher ID", createdAt: Self.now), + ], + community: community() + ) + + XCTAssertEqual(result?.1.id, "a") + XCTAssertEqual(result?.0.body, "lower ID") + } + + func testResolveSucceedsAndMutatesGatewayContent() throws { + let event = try JSONDecoder().decode( + VerifiedNostrEvent.self, + from: Data(Self.fixtureEvent.utf8) + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([event])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community()]) + ))) + + XCTAssertNotEqual(result.title, Self.gatewayBody) + XCTAssertNotEqual(result.body, Self.gatewayBody) + XCTAssertEqual(result.title, String(event.pubkey.prefix(8)) + "…") + XCTAssertEqual(result.body, "Hello Buzz") + XCTAssertEqual(result.subtitle, "Community") + XCTAssertEqual( + result.threadIdentifier, + BuzzPushPresentationIdentity.conversation( + communityID: "community-id", + channelID: Self.channelID + ) + ) + XCTAssertEqual(result.senderPubkey, event.pubkey) + XCTAssertEqual( + result.navigationTarget, + BuzzPushNavigationTarget( + eventID: event.id, + communityID: "community-id", + channelID: Self.channelID + ) + ) + } + + func testFreshVerifiedCacheResolvesSenderAvatarAndChannelWithoutRefresh() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Hello from Alice" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let avatar = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: [ + BuzzPushCachedProfile( + communityID: "community-id", + relayOrigin: "https://relay.example", + pubkey: message.pubkey, + displayName: "Alice", + pictureHash: "picture-hash", + avatarPNG: avatar, + eventID: "profile-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ], + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Alice") + XCTAssertEqual(result.senderAvatarPNG, avatar) + XCTAssertEqual(result.conversationDisplayName, "General") + XCTAssertEqual(result.conversationRecipientCount, 1) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testStaleVerifiedCacheIsUsedWhileOneBoundedRefreshFails() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Stale cache still presents" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let staleAt = Self.now - Int(BuzzPushPresentationCacheStore.freshnessLifetime) - 1 + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: [ + BuzzPushCachedProfile( + communityID: "community-id", + relayOrigin: "https://relay.example", + pubkey: message.pubkey, + displayName: "Stale Alice", + pictureHash: nil, + avatarPNG: nil, + eventID: "profile-event", + eventCreatedAt: staleAt, + cachedAt: staleAt + ) + ], + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "Stale General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: staleAt, + membershipCachedAt: staleAt, + eventID: "channel-event", + eventCreatedAt: staleAt, + cachedAt: staleAt + ) + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + XCTAssertEqual(request.timeoutInterval, 1) + return Self.response(request, status: 503, data: Data()) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Stale Alice") + XCTAssertEqual(result.conversationDisplayName, "Stale General") + XCTAssertEqual(result.conversationRecipientCount, 1) + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func testOlderVerifiedRefreshCannotReplaceNewerStaleCache() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Keep newer cached metadata" + ) + let olderProfile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now - 20, + kind: 0, + content: #"{"display_name":"Older Alice"}"# + ) + let olderChannel = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now - 20, + kind: 39_000, + tags: [["d", Self.channelID], ["name", "Older General"]] + ) + let staleAt = Self.now - Int(BuzzPushPresentationCacheStore.freshnessLifetime) - 1 + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: [ + BuzzPushCachedProfile( + communityID: "community-id", + relayOrigin: "https://relay.example", + pubkey: message.pubkey, + displayName: "Newer Cached Alice", + pictureHash: nil, + avatarPNG: nil, + eventID: String(repeating: "f", count: 64), + eventCreatedAt: Self.now - 10, + cachedAt: staleAt + ) + ], + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: olderChannel.pubkey, + displayName: "Newer Cached General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "cached-membership", + membershipEventCreatedAt: Self.now - 10, + membershipCachedAt: staleAt, + eventID: String(repeating: "f", count: 64), + eventCreatedAt: Self.now - 10, + cachedAt: staleAt + ) + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([olderProfile, olderChannel]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: olderChannel.pubkey) + ]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Newer Cached Alice") + XCTAssertEqual(result.conversationDisplayName, "Newer Cached General") + } + + func testChannelOnlyRefreshIgnoresUnrequestedProfileEvent() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Ignore unrelated enrichment" + ) + let unexpectedProfile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now + 1, + kind: 0, + content: #"{"display_name":"Unexpected Alice"}"# + ) + let relayMetadataPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let staleAt = Self.now - Int(BuzzPushPresentationCacheStore.freshnessLifetime) - 1 + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: [ + BuzzPushCachedProfile( + communityID: "community-id", + relayOrigin: "https://relay.example", + pubkey: message.pubkey, + displayName: "Cached Alice", + pictureHash: nil, + avatarPNG: nil, + eventID: "cached-profile", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ], + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayMetadataPubkey, + displayName: "Stale General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "cached-membership", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "cached-channel", + eventCreatedAt: Self.now, + cachedAt: staleAt + ) + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([unexpectedProfile]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: relayMetadataPubkey) + ]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Cached Alice") + XCTAssertEqual(result.conversationDisplayName, "Stale General") + } + + func testMissingCacheRefreshesVerifiedProfileAndChannelTogether() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Fresh metadata" + ) + let profile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 0, + content: #"{"display_name":"Fresh Alice"}"# + ) + let channel = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now, + kind: 39_000, + tags: [["d", Self.channelID], ["name", "Fresh General"], ["t", "stream"]] + ) + let membership = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now, + kind: 39_002, + tags: [ + ["d", Self.channelID], + ["p", Self.ownPubkey], + ["p", message.pubkey], + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([profile, channel, membership]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: channel.pubkey) + ]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Fresh Alice") + XCTAssertEqual(result.conversationDisplayName, "Fresh General") + XCTAssertEqual(result.conversationRecipientCount, 1) + XCTAssertNil(result.senderAvatarPNG) + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func testMalformedAndUnverifiedRefreshFallsBackWithoutBlockingMessage() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Fallback content" + ) + let validProfile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 0, + content: #"{"display_name":"Tampered"}"# + ) + let tamperedProfile = VerifiedNostrEvent( + id: validProfile.id, + pubkey: validProfile.pubkey, + createdAt: validProfile.createdAt, + kind: validProfile.kind, + tags: validProfile.tags, + content: #"{"display_name":"Mallory"}"#, + sig: validProfile.sig + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([tamperedProfile]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: try Self.pubkey(for: Self.relayPrivateKey)) + ]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, String(message.pubkey.prefix(8)) + "…") + XCTAssertNil(result.conversationDisplayName) + XCTAssertEqual(result.subtitle, "Community") + XCTAssertEqual(result.body, "Fallback content") + } + + func testOversizedPresentationRefreshFallsBackWithoutBlockingMessage() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Bounded fallback" + ) + let profile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 0, + content: #"{"display_name":"Must Not Be Used"}"# + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + var oversized = Data( + repeating: 0x20, + count: BuzzPushNotificationResolver.maximumPresentationResponseBytes + ) + oversized.append(try JSONEncoder().encode([profile])) + return Self.response(request, status: 200, data: oversized) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: try Self.pubkey(for: Self.relayPrivateKey)) + ]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, String(message.pubkey.prefix(8)) + "…") + XCTAssertNil(result.conversationDisplayName) + XCTAssertEqual(result.body, "Bounded fallback") + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func testResolveCanonicalizesWebSocketRelayOriginForQuery() throws { + URLProtocolStub.handler = { request in + XCTAssertEqual(request.url?.absoluteString, "https://relay.example/query") + return Self.response(request, status: 200, data: Data("[]".utf8)) + } + + let result = resolve( + makeResolver( + communitiesData: try snapshotData([community(relayUrl: "wss://relay.example")]) + )) + + XCTAssertNil(result) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func makeResolver( + communitiesData: Data?, + privateKeys: [String: String] = ["community-id": privateKey], + presentationCacheData: Data? = nil, + now: Date = Date() + ) -> BuzzPushNotificationResolver { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + return BuzzPushNotificationResolver( + session: URLSession(configuration: configuration), + loadCommunitiesData: { communitiesData }, + loadPrivateKey: { privateKeys[$0] }, + loadPresentationCacheData: { presentationCacheData }, + now: { now } + ) + } + + func resolve(_ resolver: BuzzPushNotificationResolver) -> BuzzPushResolution? { + let completed = expectation(description: "resolver completed") + var result: BuzzPushResolution? + resolver.resolve { + result = $0 + completed.fulfill() + } + wait(for: [completed], timeout: 2) + return result + } + + func community( + id: String = "community-id", + name: String = "Community", + relayUrl: String = "https://relay.example", + relayMetadataPubkey: String? = nil, + pubkey: String? = ownPubkey + ) -> PushLeaseCommunity { + PushLeaseCommunity( + id: id, + name: name, + relayUrl: relayUrl, + relayMetadataPubkey: relayMetadataPubkey, + pubkey: pubkey, + pushSubscriptionState: PushLeaseSubscriptionState( + authority: "accepted", + desired: [], + accepted: [ + PushLeaseSubscription( + filter: PushLeaseFilter( + kinds: [9, 40002, 45001, 45003], + hTags: [Self.channelID] + ), + notificationClass: "default" + ) + ] + ) + ) + } + + func snapshotData(_ communities: [PushLeaseCommunity]) throws -> Data { + try JSONEncoder().encode(PushLeaseSnapshot(communities: communities)) + } + + private func event( + id: String = "event-id", + pubkey: String = "author-pubkey", + content: String, + createdAt: Int = now, + kind: Int = 9, + tags: [[String]] = [] + ) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: "signature" + ) + } + + private static let fixtureEvent = #""" + {"kind":9,"created_at":1785551670,"tags":[["h","123e4567-e89b-42d3-a456-426614174000"]],"content":" Hello [Buzz](https://buzz.block.xyz) ","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","id":"233ccf24ec7c94808f9ef08b0c986b6df1bc3843ff72a9f8d016e2a77c77429b","sig":"d39dcd413839b872ed75a979b2c1542247fde636709966905c9e424e227a43897dc67b71ec84178a3faad0634f9bcdf0b48a56ebac84a2ac6e58124b8b6476e6"} + """# + + static func response( + _ request: URLRequest, + status: Int, + data: Data + ) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, data) + } + + static func pubkey(for privateKey: String) throws -> String { + let bytes = try XCTUnwrap(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: bytes) + return VerifiedNostrEvent.hex(key.xonly.bytes) + } + + static func memberDigests(_ pubkeys: [String]) -> [String] { + pubkeys.map { + BuzzPushPresentationIdentity.channelMember( + communityID: "community-id", + channelID: channelID, + pubkey: $0 + ) + }.sorted() + } + + static func signedEvent( + privateKey: String, + createdAt: Int, + kind: Int, + tags: [[String]] = [], + content: String = "" + ) throws -> VerifiedNostrEvent { + let privateKeyBytes = try XCTUnwrap(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyBytes) + let pubkey = VerifiedNostrEvent.hex(key.xonly.bytes) + let serialization = try VerifiedNostrEvent.canonicalSerialization( + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content + ) + let digest = Array(SHA256.hash(data: serialization)) + var message = digest + var randomness = [UInt8](repeating: UInt8(truncatingIfNeeded: createdAt), count: 32) + let signature = try key.signature(message: &message, auxiliaryRand: &randomness) + return VerifiedNostrEvent( + id: VerifiedNostrEvent.hex(digest), + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: VerifiedNostrEvent.hex(signature.dataRepresentation) + ) + } + + final class URLProtocolStub: URLProtocol, @unchecked Sendable { + static let lock = NSLock() + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var requests: [URLRequest] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + Self.requests.append(request) + let handler = Self.handler + Self.lock.unlock() + do { + let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }() + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + static func reset() { + lock.lock() + handler = nil + requests = [] + lock.unlock() + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift new file mode 100644 index 00000000000..ee005ed74dc --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -0,0 +1,697 @@ +import CryptoKit +import Foundation +import P256K +import Testing + +@testable import BuzzPushKit + +@Suite("Push presentation cache") +struct BuzzPushPresentationCacheTests { + private let profileKey = String(repeating: "0", count: 63) + "1" + private let relayKey = String(repeating: "0", count: 63) + "2" + private let otherRelayKey = String(repeating: "0", count: 63) + "3" + + @Test("Verified profile uses display_name, then name, and attaches a bounded local avatar") + func verifiedProfilePrecedenceAndAvatar() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore( + containerURL: directory, + now: { Date(timeIntervalSince1970: 1_700_000_100) } + ) + let event = try signedEvent( + privateKey: profileKey, + createdAt: 1_700_000_000, + kind: 0, + content: #"{"display_name":" Alice Example ","name":"alice","picture":"https://images.example/alice.png"}"# + ) + + let needsAvatar = try store.updateProfiles( + communityID: "community-a", + relayOrigin: "wss://relay.example/", + updates: [BuzzPushProfileCacheUpdate(event: event)] + ) + try store.updateProfiles( + communityID: "community-b", + relayOrigin: "wss://relay.example/", + updates: [BuzzPushProfileCacheUpdate(event: event)] + ) + + #expect(needsAvatar == Set([event.id])) + var snapshot = try loadSnapshot(directory) + var cached = try #require( + snapshot.profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + ) + ) + #expect(cached.displayName == "Alice Example") + #expect(cached.avatarPNG == nil) + #expect( + snapshot.profile( + communityID: "community-a", + relayOrigin: "https://other.example", + pubkey: event.pubkey + ) == nil + ) + + let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01]) + #expect( + try store.updateAvatar( + communityID: "community-a", + relayOrigin: "https://relay.example", + sourceURL: "https://images.example/alice.png", + avatarPNG: png + ) + ) + snapshot = try loadSnapshot(directory) + cached = try #require( + snapshot.profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + ) + ) + #expect(cached.avatarPNG == png) + #expect( + snapshot.profile( + communityID: "community-b", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + )?.avatarPNG == nil + ) + } + + @Test("Verified profile falls back from blank display_name to name") + func verifiedProfileNameFallback() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let event = try signedEvent( + privateKey: profileKey, + kind: 0, + content: #"{"display_name":" ","name":"Alice"}"# + ) + + try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: event)] + ) + + let cached = try #require( + try loadSnapshot(directory).profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + ) + ) + #expect(cached.displayName == "Alice") + } + + @Test("Malformed verified profile clears presentation while an unverified event is ignored") + func malformedAndUnverifiedProfileFallback() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let named = try signedEvent( + privateKey: profileKey, + createdAt: 100, + kind: 0, + content: #"{"name":"Alice"}"# + ) + try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: named)] + ) + let malformed = try signedEvent( + privateKey: profileKey, + createdAt: 101, + kind: 0, + content: "not-json" + ) + try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: malformed)] + ) + let tampered = VerifiedNostrEvent( + id: malformed.id, + pubkey: malformed.pubkey, + createdAt: 102, + kind: 0, + tags: [], + content: #"{"display_name":"Mallory"}"#, + sig: malformed.sig + ) + try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: tampered)] + ) + + let cached = try #require( + try loadSnapshot(directory).profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: malformed.pubkey + ) + ) + #expect(cached.eventID == malformed.id) + #expect(cached.displayName == nil) + } + + @Test("Channel name requires the expected relay signer and accepts opaque IDs") + func channelAuthorityAndOpaqueID() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let opaqueChannelID = "channel/general:v5" + let verified = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", opaqueChannelID], ["name", " General Chat "], ["t", "stream"]] + ) + let wrongSigner = try signedEvent( + privateKey: otherRelayKey, + createdAt: 101, + kind: 39_000, + tags: [["d", opaqueChannelID], ["name", "Impostor"]] + ) + + try store.updateChannels( + communityID: "community-a", + relayOrigin: "wss://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: [wrongSigner, verified], + membershipEvents: [] + ) + + let cached = try #require( + try loadSnapshot(directory).channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: opaqueChannelID + ) + ) + #expect(cached.eventID == verified.id) + #expect(cached.displayName == "General Chat") + #expect(cached.channelType == "stream") + #expect(cached.relayMetadataPubkey == relayPubkey) + } + + @Test("Channel membership requires the same relay authority and keeps exact scoped digests") + func channelMembershipAuthorityAndOrdering() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let firstMember = try pubkey(for: profileKey) + let secondMember = try pubkey(for: otherRelayKey) + let metadata = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "General"], ["t", "stream"]] + ) + let newestMembership = try signedEvent( + privateKey: relayKey, + createdAt: 102, + kind: 39_002, + tags: [ + ["d", "opaque-channel"], + ["p", firstMember], + ["p", secondMember], + ["p", secondMember], + ] + ) + let olderMembership = try signedEvent( + privateKey: relayKey, + createdAt: 101, + kind: 39_002, + tags: [["d", "opaque-channel"], ["p", firstMember]] + ) + let wrongSigner = try signedEvent( + privateKey: otherRelayKey, + createdAt: 103, + kind: 39_002, + tags: [["d", "opaque-channel"], ["p", firstMember]] + ) + let malformed = try signedEvent( + privateKey: relayKey, + createdAt: 104, + kind: 39_002, + tags: [["d", "opaque-channel"], ["p", "not-a-pubkey"]] + ) + + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: [metadata], + membershipEvents: [wrongSigner, malformed, newestMembership, olderMembership] + ) + + let cached = try #require( + try loadSnapshot(directory).channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: "opaque-channel" + ) + ) + #expect(cached.memberCount == 2) + #expect(cached.membershipEventID == newestMembership.id) + #expect( + cached.memberDigests == [firstMember, secondMember].map { + BuzzPushPresentationIdentity.channelMember( + communityID: "community-a", + channelID: "opaque-channel", + pubkey: $0 + ) + }.sorted() + ) + } + + @Test("Channel authority rotation clears membership signed by the old authority") + func channelAuthorityRotationClearsMembership() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let rotatedRelayPubkey = try pubkey(for: otherRelayKey) + let member = try pubkey(for: profileKey) + let metadata = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "General"], ["t", "stream"]] + ) + let membership = try signedEvent( + privateKey: relayKey, + createdAt: 101, + kind: 39_002, + tags: [["d", "opaque-channel"], ["p", member]] + ) + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: [metadata], + membershipEvents: [membership] + ) + + let rotatedMetadata = try signedEvent( + privateKey: otherRelayKey, + createdAt: 50, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "General"], ["t", "stream"]] + ) + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: rotatedRelayPubkey, + metadataEvents: [rotatedMetadata], + membershipEvents: [] + ) + + let cached = try #require( + try loadSnapshot(directory).channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: "opaque-channel" + ) + ) + #expect(cached.relayMetadataPubkey == rotatedRelayPubkey) + #expect(cached.eventID == rotatedMetadata.id) + #expect(cached.memberCount == nil) + #expect(cached.memberDigests == nil) + #expect(cached.membershipEventID == nil) + } + + @Test("Oversized channel batches are ignored before cache mutation") + func oversizedChannelBatchIsIgnored() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let initial = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "Initial"], ["t", "stream"]] + ) + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: [initial], + membershipEvents: [] + ) + let replacement = try signedEvent( + privateKey: relayKey, + createdAt: 101, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "Replacement"], ["t", "stream"]] + ) + + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: Array( + repeating: replacement, + count: BuzzPushPresentationCacheStore.maximumChannels + 1 + ), + membershipEvents: [] + ) + + let cached = try #require( + try loadSnapshot(directory).channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: "opaque-channel" + ) + ) + #expect(cached.eventID == initial.id) + #expect(cached.displayName == "Initial") + } + + @Test("Global member-digest bound drops oldest complete rosters first") + func globalMembershipDigestBound() throws { + let membersPerChannel = BuzzPushPresentationCacheStore.maximumMembersPerChannel + let channelCount = + BuzzPushPresentationCacheStore.maximumTotalMemberDigests / membersPerChannel + 1 + let digests = (0.. URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("buzz-push-cache-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func loadSnapshot(_ directory: URL) throws -> BuzzPushPresentationCacheSnapshot { + let data = try Data( + contentsOf: directory.appendingPathComponent(BuzzPushPresentationCacheStore.fileName) + ) + return try JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.self, from: data) + } + + private func pubkey(for privateKey: String) throws -> String { + let bytes = try #require(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: bytes) + return VerifiedNostrEvent.hex(key.xonly.bytes) + } + + private func signedEvent( + privateKey: String, + createdAt: Int = 1_700_000_000, + kind: Int, + tags: [[String]] = [], + content: String = "" + ) throws -> VerifiedNostrEvent { + let privateKeyBytes = try #require(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyBytes) + let pubkey = VerifiedNostrEvent.hex(key.xonly.bytes) + let serialization = try VerifiedNostrEvent.canonicalSerialization( + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content + ) + let digest = Array(SHA256.hash(data: serialization)) + var message = digest + var randomness = [UInt8](repeating: UInt8(truncatingIfNeeded: createdAt), count: 32) + let signature = try key.signature(message: &message, auxiliaryRand: &randomness) + return VerifiedNostrEvent( + id: VerifiedNostrEvent.hex(digest), + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: VerifiedNostrEvent.hex(signature.dataRepresentation) + ) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift new file mode 100644 index 00000000000..3cb242bc4cf --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift @@ -0,0 +1,159 @@ +import CryptoKit +import Foundation +import XCTest + +@testable import BuzzPushKit + +/// Replays the gateway-generated known-answer vectors +/// (`crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json`) +/// against the Swift canonical encoder. Byte-for-byte transcript equality and +/// SHA-256 equality are both asserted, so a drift on either side breaks a test +/// instead of silently stranding iOS clients with `401 invalid_attestation`. +final class BuzzPushTranscriptTests: XCTestCase { + // MARK: Fixture + + struct Fixture: Decodable { + struct Vector: Decodable { + let name: String + let domain: String + let transcript: String + let sha256: String + } + + let vectors: [Vector] + } + + static func fixture( + file: StaticString = #filePath, + line: UInt = #line + ) throws -> Fixture { + let path = try XCTUnwrap( + Bundle.module.url( + forResource: "app_attest_transcripts", + withExtension: "json" + ), + "missing bundled gateway transcript fixture app_attest_transcripts.json in \(Bundle.module.bundleURL.path)", + file: file, + line: line + ) + let data = try Data(contentsOf: path) + return try JSONDecoder().decode(Fixture.self, from: data) + } + + // Deterministic inputs mirroring the fixture's `inputs` block. + static let challengeId = UUID(uuidString: "11111111-1111-4111-8111-111111111111")! + 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 + static let expiresAt: Int64 = 1_752_624_000 + + private func assertMatchesVector(_ name: String, _ bytes: Data, + file: StaticString = #filePath, line: UInt = #line) throws { + guard let vector = try Self.fixture(file: file, line: line).vectors.first(where: { $0.name == name }) else { + XCTFail("missing fixture vector \(name)", file: file, line: line) + return + } + XCTAssertEqual(String(decoding: bytes, as: UTF8.self), vector.transcript, + "\(name) transcript bytes drifted from gateway ground truth", + file: file, line: line) + let digest = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + XCTAssertEqual(digest, vector.sha256, + "\(name) sha256 drifted from gateway ground truth", + file: file, line: line) + } + + // MARK: Known-answer vectors + + func testEnrollVector() throws { + try assertMatchesVector("enroll", BuzzPushTranscript.enroll( + challengeId: Self.challengeId, + challenge: Self.challenge, + keyId: Self.keyId, + appProfile: Self.appProfile, + endpoint: Self.endpoint, + endpointEpoch: 1, + expiresAt: Self.expiresAt + )) + } + + func testDelegateVector() throws { + try assertMatchesVector("delegate", BuzzPushTranscript.delegate( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + generation: 1, + relayPubkey: Self.relayPubkey, + notBefore: Self.notBefore, + expiresAt: Self.expiresAt + )) + } + + func testRotateEndpointVector() throws { + try assertMatchesVector("rotate_endpoint", BuzzPushTranscript.rotateEndpoint( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + newEndpointEpoch: 2, + endpoint: Self.endpoint + )) + } + + func testRevokeDelegationVector() throws { + try assertMatchesVector("revoke_delegation", BuzzPushTranscript.revokeDelegation( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + relayPubkey: Self.relayPubkey, + generation: 2 + )) + } + + func testRevokeInstallationVector() throws { + try assertMatchesVector("revoke_installation", BuzzPushTranscript.revokeInstallation( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + newEndpointEpoch: 2 + )) + } + + func testAllFixtureVectorsCovered() throws { + XCTAssertEqual( + Set(try Self.fixture().vectors.map(\.name)), + ["enroll", "delegate", "rotate_endpoint", "revoke_delegation", "revoke_installation"], + "fixture gained or lost a vector; add/remove the matching known-answer test" + ) + } + + // MARK: Escaping edges (the exact JSONSerialization failure modes) + + func testSolidusIsNotEscaped() throws { + // The whole reason this encoder exists: '/' must pass through raw. + XCTAssertEqual(try BuzzPushTranscript.CanonicalObject.escape("https://push.buzz.xyz/v1", field: "audience"), + "https://push.buzz.xyz/v1") + } + + func testMinimalEscaping() throws { + XCTAssertEqual(try BuzzPushTranscript.CanonicalObject.escape("a\"b\\c\u{08}\u{09}\u{0A}\u{0C}\u{0D}\u{01}", field: "x"), + "a\\\"b\\\\c\\b\\t\\n\\f\\r\\u0001") + } + + func testNonASCIIRejected() { + XCTAssertThrowsError(try BuzzPushTranscript.CanonicalObject.escape("caf\u{00E9}", field: "app_profile")) { + XCTAssertEqual($0 as? BuzzPushTranscriptError, .nonASCIIInput(field: "app_profile")) + } + } + + func testUUIDLowercased() throws { + var o = BuzzPushTranscript.CanonicalObject() + o.uuid("k", UUID(uuidString: "ABCDEF12-3456-4789-8ABC-DEF123456789")!) + XCTAssertEqual(o.encoded(), "{\"k\":\"abcdef12-3456-4789-8abc-def123456789\"}") + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Fixtures b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Fixtures new file mode 120000 index 00000000000..cbfddb7a246 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Fixtures @@ -0,0 +1 @@ +../../../../../crates/buzz-push-gateway/tests/vectors \ No newline at end of file diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift new file mode 100644 index 00000000000..9cffaa5cd28 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift @@ -0,0 +1,76 @@ +import CryptoKit +import Foundation +import XCTest + +@testable import BuzzPushKit + +final class NostrHTTPAuthTests: XCTestCase { + private let privateKey = String(repeating: "0", count: 63) + "1" + private let expectedPubkey = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + + func testAuthorizationHeaderConstructsValidNIP98Event() throws { + let body = Data("[{\"kinds\":[9]}]".utf8) + let url = URL(string: "https://relay.example/query")! + let header = try NostrHTTPAuth.authorizationHeader( + url: url, + method: "post", + body: body, + privateKeyHex: privateKey, + createdAt: 1_700_000_000, + auxiliaryRandomness: [UInt8](repeating: 0, count: 32) + ) + + XCTAssertTrue(header.hasPrefix("Nostr ")) + let encoded = try XCTUnwrap(Data(base64Encoded: String(header.dropFirst(6)))) + let event = try JSONDecoder().decode(VerifiedNostrEvent.self, from: encoded) + XCTAssertEqual(event.pubkey, expectedPubkey) + XCTAssertEqual(event.createdAt, 1_700_000_000) + XCTAssertEqual(event.kind, 27235) + XCTAssertEqual(event.content, "") + XCTAssertEqual(event.tags, [ + ["u", "https://relay.example/query"], + ["method", "POST"], + ["payload", SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined()], + ]) + XCTAssertTrue(event.hasValidIDAndSignature()) + } + + func testEventVerificationRejectsChangedIDSignatureAndContent() throws { + let event = try makeEvent() + XCTAssertTrue(event.hasValidIDAndSignature()) + XCTAssertFalse(copy(event, id: String(repeating: "0", count: 64)).hasValidIDAndSignature()) + XCTAssertFalse(copy(event, sig: String(repeating: "0", count: 128)).hasValidIDAndSignature()) + XCTAssertFalse(copy(event, content: "tampered").hasValidIDAndSignature()) + } + + private func makeEvent() throws -> VerifiedNostrEvent { + let header = try NostrHTTPAuth.authorizationHeader( + url: URL(string: "https://relay.example/query")!, + method: "POST", + body: Data(), + privateKeyHex: privateKey, + createdAt: 1_700_000_000, + auxiliaryRandomness: [UInt8](repeating: 0, count: 32) + ) + let data = try XCTUnwrap(Data(base64Encoded: String(header.dropFirst(6)))) + return try JSONDecoder().decode(VerifiedNostrEvent.self, from: data) + } + + private func copy( + _ event: VerifiedNostrEvent, + id: String? = nil, + content: String? = nil, + sig: String? = nil + ) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: id ?? event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: content ?? event.content, + sig: sig ?? event.sig + ) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift new file mode 100644 index 00000000000..3c8190d3934 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift @@ -0,0 +1,109 @@ +import XCTest + +@testable import BuzzPushKit + +final class PushLeaseTests: XCTestCase { + private let mine = String(repeating: "a", count: 64) + private let other = String(repeating: "b", count: 64) + + func testDesiredAuthorityIsExplicitAndAcceptedAuthorityRequiresState() throws { + let subscription = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]), + notificationClass: "default" + ) + XCTAssertEqual( + try PushLeaseSubscriptionState( + authority: "desired", + desired: [subscription] + ).authoritativeSubscriptions(), + [subscription] + ) + XCTAssertThrowsError( + try PushLeaseSubscriptionState( + authority: "accepted", + desired: [subscription] + ).authoritativeSubscriptions() + ) { error in + XCTAssertEqual(error as? PushLeaseError, .acceptedAuthorityMissingSubscriptions) + } + } + + func testFilterBuildsQueryFromLeaseWithoutHardcodedKinds() { + let filter = PushLeaseFilter( + kinds: [7, 1059], + authors: [other], + pTags: [mine], + hTags: ["channel"], + eTags: [String(repeating: "c", count: 64)] + ) + let query = filter.queryFilter(since: 1_000, limit: 10) + + XCTAssertEqual(query["kinds"] as? [Int], [7, 1059]) + XCTAssertEqual(query["authors"] as? [String], [other]) + XCTAssertEqual(query["#p"] as? [String], [mine]) + XCTAssertEqual(query["#h"] as? [String], ["channel"]) + XCTAssertEqual(query["since"] as? Int, 1_000) + } + + func testPushEligibleKindAbsentFromOldConstantMatchesLease() { + let event = makeEvent(kind: 1059, tags: [["p", mine]]) + let subscription = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [1059], pTags: [mine]), + notificationClass: "default" + ) + + XCTAssertTrue(PushLeaseMatcher.matches(event: event, subscription: subscription)) + } + + func testIgnoreAndHellthreadSuppressionRejectCandidates() { + let ignored = makeEvent(kind: 9, pubkey: other, tags: [["p", mine]]) + let ignoreSubscription = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]), + notificationClass: "default", + ignore: [PushLeaseFilter(kinds: [9], authors: [other])] + ) + XCTAssertFalse( + PushLeaseMatcher.matches(event: ignored, subscription: ignoreSubscription) + ) + + let hellthread = makeEvent( + kind: 9, + tags: (0..<21).map { ["p", String(format: "%064x", $0)] } + ) + let suppressed = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], authors: [other]), + notificationClass: "default", + suppress: PushLeaseSuppression(pTagsMax: 20) + ) + XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, subscription: suppressed)) + } + + func testDecodesSnapshotContractFromDartShape() throws { + let json = """ + {"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","pushSubscriptionState":{"authority":"desired","desired":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"class":"default","ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}}]} + """ + let snapshot = try JSONDecoder().decode(PushLeaseSnapshot.self, from: Data(json.utf8)) + + XCTAssertEqual(snapshot.communities.count, 1) + XCTAssertEqual( + try snapshot.communities[0].pushSubscriptionState.authoritativeSubscriptions().count, + 1 + ) + } + + private func makeEvent( + kind: Int, + pubkey: String? = nil, + tags: [[String]] = [] + ) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: String(repeating: "d", count: 64), + pubkey: pubkey ?? other, + createdAt: 1_000, + kind: kind, + tags: tags, + content: "message", + sig: String(repeating: "e", count: 128) + ) + } +} diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 1f35905051e..439a9dc6e76 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -1,12 +1,23 @@ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG // Default app bundle identifier. Internal/custom builds can override this // without patching tracked files by writing // `mobile/ios/Flutter/AppOverrides.xcconfig` containing // `BUNDLE_IDENTIFIER = your.app.id` (gitignored). -BUNDLE_IDENTIFIER = com.buzz.buzzMobile +BUNDLE_IDENTIFIER = xyz.block.buzz.dogfood.mobile APP_DISPLAY_NAME = Buzz +BUZZ_DEVELOPMENT_TEAM = JMTDPW9CG3 + +// Push is a build capability, not a runtime rollout switch. Normal Debug +// builds compile out the bridge and exclude the extension product. Internal +// dogfood builds opt in by including PushEnabled.xcconfig from the ignored +// AppOverrides.xcconfig file. +BUZZ_PUSH_ENABLED = NO +BUZZ_INFO_PLIST = Runner/Info.plist +BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements +EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift PushEndpointGrantStore.swift PushPresentationCacheBridge.swift // Worktree-aware debug identity (gitignored, written by // scripts/mobile-worktree-overrides.sh): a per-worktree bundle identifier diff --git a/mobile/ios/Flutter/PushEnabled.xcconfig b/mobile/ios/Flutter/PushEnabled.xcconfig new file mode 100644 index 00000000000..c088a384a5d --- /dev/null +++ b/mobile/ios/Flutter/PushEnabled.xcconfig @@ -0,0 +1,16 @@ +// Complete internal iOS push capability overlay. This file is inert unless an +// ignored AppOverrides.xcconfig explicitly includes it. +BUZZ_PUSH_ENABLED = YES +BUZZ_INFO_PLIST = Runner/RunnerPush-Info.plist +BUNDLE_IDENTIFIER = xyz.block.buzz.dogfood.mobile +BUZZ_DEVELOPMENT_TEAM = JMTDPW9CG3 +BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) +BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) +BUZZ_IOS_PUSH_ENVIRONMENT = production +BUZZ_APP_ATTEST_ENVIRONMENT = production +BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/RunnerPush.entitlements +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) BUZZ_PUSH_ENABLED +EXCLUDED_SOURCE_FILE_NAMES = + +// BUZZ_PUSH_ENABLED=true, base64-encoded for Flutter's DART_DEFINES setting. +DART_DEFINES = $(inherited),QlVaWl9QVVNIX0VOQUJMRUQ9dHJ1ZQ== diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index d287c5fb432..29eee3fc778 100644 --- a/mobile/ios/Flutter/Release.xcconfig +++ b/mobile/ios/Flutter/Release.xcconfig @@ -4,8 +4,16 @@ // Defaults for the iOS Release build. Internal/custom builds (e.g. an // enterprise-signed distribution) override these without patching tracked // files by writing `mobile/ios/Flutter/AppOverrides.xcconfig` (gitignored). -BUNDLE_IDENTIFIER = com.buzz.buzzMobile +BUNDLE_IDENTIFIER = xyz.block.buzz.mobile APP_DISPLAY_NAME = Buzz CODE_SIGN_STYLE = Automatic CODE_SIGN_IDENTITY = iPhone Developer +BUZZ_DEVELOPMENT_TEAM = EYF346PHUG + +// App Store builds remain dormant until a later rollout. The internal build +// pipeline explicitly includes PushEnabled.xcconfig from AppOverrides.xcconfig. +BUZZ_PUSH_ENABLED = NO +BUZZ_INFO_PLIST = Runner/Info.plist +BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements +EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift PushEndpointGrantStore.swift PushPresentationCacheBridge.swift #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/NotificationService/Info.plist b/mobile/ios/NotificationService/Info.plist new file mode 100644 index 00000000000..e66f3a9505d --- /dev/null +++ b/mobile/ios/NotificationService/Info.plist @@ -0,0 +1,35 @@ + + + + + BuzzAppGroupIdentifier + $(BUZZ_APP_GROUP_IDENTIFIER) + BuzzKeychainAccessGroup + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/mobile/ios/NotificationService/NotificationService.entitlements b/mobile/ios/NotificationService/NotificationService.entitlements new file mode 100644 index 00000000000..2187d2c03bd --- /dev/null +++ b/mobile/ios/NotificationService/NotificationService.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + $(BUZZ_APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + + + diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift new file mode 100644 index 00000000000..644677c04f0 --- /dev/null +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -0,0 +1,142 @@ +import BuzzPushKit +import Foundation +import Security +import UserNotifications + +final class NotificationService: UNNotificationServiceExtension { + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + private let communicationPresenter = BuzzCommunicationNotificationPresenter() + private lazy var resolver: BuzzPushNotificationResolving = { + let appGroupIdentifier = + Bundle.main.object( + forInfoDictionaryKey: "BuzzAppGroupIdentifier" + ) as? String + let keychainAccessGroup = + Bundle.main.object( + forInfoDictionaryKey: "BuzzKeychainAccessGroup" + ) as? String + return BuzzPushNotificationResolver( + session: .shared, + loadCommunitiesData: { + Self.loadCommunitiesData(appGroupIdentifier: appGroupIdentifier) + }, + loadPrivateKey: { communityID in + Self.loadPrivateKey( + communityID: communityID, + keychainAccessGroup: keychainAccessGroup + ) + }, + loadPresentationCacheData: { + Self.loadAppGroupData( + fileName: BuzzPushPresentationCacheStore.fileName, + appGroupIdentifier: appGroupIdentifier, + maximumBytes: BuzzPushPresentationCacheStore.maximumSnapshotBytes + ) + } + ) + }() + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { + contentHandler(request.content) + return + } + bestAttemptContent = content + var cleanUserInfo = content.userInfo + cleanUserInfo.removeValue(forKey: BuzzPushNavigationTarget.userInfoKey) + content.userInfo = cleanUserInfo + + resolver.resolve { [weak self] resolution in + guard let self else { return } + if let resolution { + content.title = resolution.title + content.body = resolution.body + if let subtitle = resolution.subtitle { + content.subtitle = subtitle + } + if let threadIdentifier = resolution.threadIdentifier { + content.threadIdentifier = threadIdentifier + } + if let navigationTarget = resolution.navigationTarget { + var userInfo = content.userInfo + userInfo[BuzzPushNavigationTarget.userInfoKey] = navigationTarget.userInfoValue + content.userInfo = userInfo + } + self.bestAttemptContent = content + self.communicationPresenter.present( + ordinaryContent: content, + resolution: resolution + ) { [weak self] specializedContent in + self?.finish(specializedContent) + } + return + } + self.finish(content) + } + } + + override func serviceExtensionTimeWillExpire() { + if let bestAttemptContent { + finish(bestAttemptContent) + } + } + + private func finish(_ content: UNNotificationContent) { + guard let contentHandler else { return } + self.contentHandler = nil + contentHandler(content) + } + + private static func loadPrivateKey( + communityID: String, + keychainAccessGroup: String? + ) -> String? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "buzz.push.nse.signing", + kSecAttrAccount as String: communityID, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + if let keychainAccessGroup, !keychainAccessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = keychainAccessGroup + } + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data + else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func loadCommunitiesData(appGroupIdentifier: String?) -> Data? { + loadAppGroupData( + fileName: "push-communities.json", + appGroupIdentifier: appGroupIdentifier + ) + } + + private static func loadAppGroupData( + fileName: String, + appGroupIdentifier: String?, + maximumBytes: Int? = nil + ) -> Data? { + guard let appGroupIdentifier, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + else { return nil } + let fileURL = container.appendingPathComponent(fileName) + if let maximumBytes { + guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), + let fileSize = values.fileSize, + fileSize <= maximumBytes + else { return nil } + } + return try? Data(contentsOf: fileURL) + } +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index f6f66ee2dc2..09ec7a94dfa 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + BZZ00000000000000000001 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000006 /* NotificationService.swift */; }; + BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000009 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + BZZ00000000000000000020 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; }; + BZZ00000000000000000025 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C809A294A618700263BE5 /* MediaSanitizer.swift */; }; @@ -26,6 +30,10 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + BZZ00000000000000000023 /* PushNativeState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000024 /* PushNativeState.swift */; }; + BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000027 /* PushEndpointGrantStore.swift */; }; + BZZ00000000000000000029 /* PushPresentationCacheBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002A /* PushPresentationCacheBridge.swift */; }; + BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -43,6 +51,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + BZZ00000000000000000004 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -56,6 +75,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + BZZ00000000000000000006 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = ""; }; + BZZ00000000000000000009 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + BZZ0000000000000000000A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Runner.entitlements; sourceTree = ""; }; + BZZ00000000000000000028 /* RunnerPush.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = RunnerPush.entitlements; sourceTree = ""; }; + BZZ0000000000000000002D /* RunnerPush-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "RunnerPush-Info.plist"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; @@ -80,6 +106,10 @@ 57A155722F02B92C397E5AE2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + BZZ00000000000000000024 /* PushNativeState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNativeState.swift; sourceTree = ""; }; + BZZ00000000000000000027 /* PushEndpointGrantStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEndpointGrantStore.swift; sourceTree = ""; }; + BZZ0000000000000000002A /* PushPresentationCacheBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPresentationCacheBridge.swift; sourceTree = ""; }; + BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuzzCommunicationNotificationTests.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; @@ -96,6 +126,14 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + BZZ0000000000000000000C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BZZ00000000000000000020 /* BuzzPushKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3C28C6B702C81085E6F96F2A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -108,6 +146,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BZZ00000000000000000025 /* BuzzPushKit in Frameworks */, 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -119,6 +158,7 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */, 331C80A0294A618700263BE5 /* Fixtures */, ); path = RunnerTests; @@ -161,6 +201,7 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + BZZ0000000000000000000B /* NotificationService */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, @@ -175,6 +216,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + BZZ00000000000000000009 /* NotificationService.appex */, ); name = Products; sourceTree = ""; @@ -186,9 +228,15 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + BZZ0000000000000000000A /* Runner.entitlements */, + BZZ00000000000000000028 /* RunnerPush.entitlements */, + BZZ0000000000000000002D /* RunnerPush-Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + BZZ00000000000000000024 /* PushNativeState.swift */, + BZZ00000000000000000027 /* PushEndpointGrantStore.swift */, + BZZ0000000000000000002A /* PushPresentationCacheBridge.swift */, 331C809A294A618700263BE5 /* MediaSanitizer.swift */, 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */, 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */, @@ -215,6 +263,16 @@ name = Frameworks; sourceTree = ""; }; + BZZ0000000000000000000B /* NotificationService */ = { + isa = PBXGroup; + children = ( + BZZ00000000000000000006 /* NotificationService.swift */, + BZZ00000000000000000007 /* Info.plist */, + BZZ00000000000000000008 /* NotificationService.entitlements */, + ); + path = NotificationService; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -247,6 +305,7 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + BZZ00000000000000000004 /* Embed App Extensions */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */, ); @@ -255,10 +314,33 @@ dependencies = ( ); name = Runner; + packageProductDependencies = ( + BZZ00000000000000000022 /* BuzzPushKit */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + BZZ0000000000000000000E /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = BZZ0000000000000000000F /* Build configuration list for PBXNativeTarget "NotificationService" */; + buildPhases = ( + BZZ0000000000000000000D /* Sources */, + BZZ0000000000000000000C /* Frameworks */, + BZZ00000000000000000010 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NotificationService; + packageProductDependencies = ( + BZZ00000000000000000022 /* BuzzPushKit */, + ); + productName = NotificationService; + productReference = BZZ00000000000000000009 /* NotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -269,6 +351,9 @@ LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { + BZZ0000000000000000000E = { + CreatedOnToolsVersion = 15.0; + }; 331C8080294A63A400263BE5 = { CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; @@ -288,17 +373,28 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + BZZ0000000000000000000E /* NotificationService */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + BZZ00000000000000000010 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807F294A63A400263BE5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -417,11 +513,20 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + BZZ0000000000000000000D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BZZ00000000000000000001 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807D294A63A400263BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -430,6 +535,9 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + BZZ00000000000000000023 /* PushNativeState.swift in Sources */, + BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */, + BZZ00000000000000000029 /* PushPresentationCacheBridge.swift in Sources */, 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */, 4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */, 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */, @@ -456,6 +564,21 @@ }; /* End PBXTargetDependency section */ +/* Begin XCLocalSwiftPackageReference section */ + BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = BuzzPushKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + BZZ00000000000000000022 /* BuzzPushKit */ = { + isa = XCSwiftPackageProductDependency; + package = BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */; + productName = BuzzPushKit; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -535,9 +658,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; + CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"; + INFOPLIST_FILE = "$(BUZZ_INFO_PLIST)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -723,9 +847,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; + CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"; + INFOPLIST_FILE = "$(BUZZ_INFO_PLIST)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -746,9 +871,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; + CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"; + INFOPLIST_FILE = "$(BUZZ_INFO_PLIST)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -761,9 +887,91 @@ }; name = Release; }; + BZZ00000000000000000011 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + BZZ00000000000000000012 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + BZZ00000000000000000015 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + BZZ0000000000000000000F /* Build configuration list for PBXNativeTarget "NotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BZZ00000000000000000011 /* Debug */, + BZZ00000000000000000012 /* Release */, + BZZ00000000000000000015 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..320fe8c569c --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "08a49d61c7de953c8fb77e34cc8578189c85a707e518c346697669ad28235ec0", + "pins" : [ + { + "identity" : "swift-secp256k1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/21-DOT-DEV/swift-secp256k1.git", + "state" : { + "revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2", + "version" : "0.21.1" + } + } + ], + "version" : 3 +} diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index b42f86326c0..9779b032c4c 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,11 +1,31 @@ import AVFoundation import Flutter import UIKit -import UserNotifications + +#if BUZZ_PUSH_ENABLED + import BuzzPushKit + import UserNotifications +#endif @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private var mediaUploadChannel: FlutterMethodChannel? + #if BUZZ_PUSH_ENABLED + private var pushChannel: FlutterMethodChannel? + private let apnsRegistrationBuffer = APNsRegistrationBuffer() + private let pushNavigationBuffer = BuzzPushNavigationBuffer() + private var apnsDeviceToken: Data? + private lazy var endpointGrantStore = BuzzPushEndpointGrantKeychainStore( + accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + ) + private var enrollmentTask: Task? + private var appGroupIdentifier: String? { + Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String + } + private lazy var pushPresentationCacheBridge = BuzzPushPresentationCacheBridge( + appGroupIdentifier: appGroupIdentifier + ) + #endif private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? private var concentricSheetSurfaceChannel: FlutterMethodChannel? @@ -17,7 +37,9 @@ import UserNotifications _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in } + #if BUZZ_PUSH_ENABLED + UNUserNotificationCenter.current().delegate = self + #endif return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -31,6 +53,18 @@ import UserNotifications mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in self?.handleMediaUploadMethodCall(call, result: result) } + #if BUZZ_PUSH_ENABLED + pushChannel = FlutterMethodChannel( + name: "buzz/push", + binaryMessenger: messenger + ) + pushChannel?.setMethodCallHandler { [weak self] call, result in + self?.handlePushMethodCall(call, result: result) + } + apnsRegistrationBuffer.attach { [weak self] update in + self?.pushChannel?.invokeMethod(update.method, arguments: update.arguments) + } + #endif qrScannerChannel = FlutterMethodChannel( name: "buzz/qr_scanner", binaryMessenger: messenger @@ -204,6 +238,298 @@ import UserNotifications .safeAreaInsets.top ?? 0 } + #if BUZZ_PUSH_ENABLED + override func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) + apnsDeviceToken = deviceToken + apnsRegistrationBuffer.recordToken(deviceToken) + } + + override func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + super.application(application, didFailToRegisterForRemoteNotificationsWithError: error) + apnsRegistrationBuffer.recordError(error.localizedDescription) + } + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: response.actionIdentifier, + userInfo: response.notification.request.content.userInfo, + onTarget: { target in + pushNavigationBuffer.record(target) + deliverPushNavigationTarget(target) + }, + forwardToFlutter: { pluginCompletion in + self.forwardPushNotificationResponseToFlutter( + center, + response: response, + completion: pluginCompletion + ) + }, + completion: completionHandler + ) + } + + private func forwardPushNotificationResponseToFlutter( + _ center: UNUserNotificationCenter, + response: UNNotificationResponse, + completion: @escaping () -> Void + ) { + super.userNotificationCenter( + center, + didReceive: response, + withCompletionHandler: completion + ) + } + + private func deliverPushNavigationTarget(_ target: BuzzPushNavigationTarget) { + pushChannel?.invokeMethod( + "notificationOpened", + arguments: target.flutterArguments + ) { [weak self] result in + guard result as? String == "handled" else { return } + self?.pushNavigationBuffer.remove(ifMatching: target) + } + } + + private func handlePushMethodCall( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + if pushPresentationCacheBridge.handle(call, result: result) { + return + } + switch call.method { + case "requestAuthorization": + requestPushAuthorization(result: result) + case "takePendingNotificationResponse": + result(pushNavigationBuffer.take()?.flutterArguments) + case "saveCommunitySnapshot": + guard let arguments = call.arguments as? [String: Any], + let communities = arguments["communities"] as? [[String: Any]], + let signingKeys = arguments["signingKeys"] as? [String: String] + else { + result( + FlutterError( + code: "invalid_arguments", message: "Expected communities array.", details: nil)) + return + } + do { + try savePushCommunitySnapshot(communities) + try BuzzPushKeychain.replace( + signingKeys: signingKeys, + accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") + as? String + ) + result(nil) + } catch { + result( + FlutterError( + code: "save_failed", message: "Unable to save push community credentials.", + details: error.localizedDescription)) + } + case "endpointGrants": + do { + result(try endpointGrantStore.records().map(\.flutterArguments)) + } catch { + result( + FlutterError( + code: "endpoint_grant_read_failed", + message: "Unable to read persisted push endpoint grants.", + details: error.localizedDescription + ) + ) + } + case "enrollPush": + handleDevPushEnrollment(call, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func requestPushAuthorization(result: @escaping FlutterResult) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { + granted, error in + DispatchQueue.main.async { + if let error { + result( + FlutterError( + code: "notification_authorization_failed", + message: "Unable to request notification authorization.", + details: error.localizedDescription + ) + ) + return + } + guard granted else { + result(false) + return + } + UIApplication.shared.registerForRemoteNotifications() + result(true) + } + } + } + + private func handleDevPushEnrollment( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard enrollmentTask == nil else { + result( + FlutterError( + code: "enrollment_in_progress", + message: "Development push enrollment is already running.", + details: nil + ) + ) + return + } + guard let deviceToken = apnsDeviceToken else { + result( + FlutterError( + code: "missing_apns_token", + message: "APNs has not supplied a device token.", + details: nil + ) + ) + return + } + guard !deviceToken.isEmpty else { + result( + FlutterError( + code: "invalid_apns_token", + message: "APNs supplied an empty device token.", + details: nil + ) + ) + return + } + guard let arguments = call.arguments as? [String: Any], + let relayText = arguments["relayUrl"] as? String, + let relayURL = URL(string: relayText), + let gatewayText = arguments["gatewayUrl"] as? String, + let gatewayURL = URL(string: gatewayText) + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Development push enrollment requires relayUrl and gatewayUrl.", + details: nil + ) + ) + return + } + + do { + let driver: BuzzDevPushEnrollmentDriver + #if DEBUG + driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore + ) + #else + driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore, + appAttestKeychainAccessGroup: Bundle.main.object( + forInfoDictionaryKey: "BuzzKeychainAccessGroup" + ) as? String + ) + #endif + enrollmentTask = Task { [weak self] in + defer { self?.enrollmentTask = nil } + do { + let record = try await driver.enroll( + deviceToken: deviceToken, + relayURL: relayURL + ) + await MainActor.run { result(record.flutterArguments) } + } catch { + await MainActor.run { + result( + FlutterError( + code: "dev_enrollment_failed", + message: "Development push enrollment failed.", + details: error.localizedDescription + ) + ) + } + } + } + } catch { + result( + FlutterError( + code: "dev_enrollment_configuration_failed", + message: "Development push enrollment is not configured.", + details: error.localizedDescription + ) + ) + } + } + + private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws { + guard let appGroupIdentifier else { + throw NSError( + domain: "BuzzPush", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Missing BuzzAppGroupIdentifier"]) + } + guard + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier) + else { + throw NSError( + domain: "BuzzPush", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Missing App Group container"]) + } + // Channel-name enrichment is optional presentation state. A damaged or + // unavailable grant cache must not block the core NSE snapshot/key update. + let grants = (try? endpointGrantStore.records()) ?? [] + let enriched = communities.map { community -> [String: Any] in + var community = community + guard let relayURL = community["relayUrl"] as? String, + let relayMetadataPubkey = Self.pushRelayMetadataPubkey( + relayURL: relayURL, + grants: grants + ) + else { return community } + community["relayMetadataPubkey"] = relayMetadataPubkey + return community + } + let data = try JSONSerialization.data( + withJSONObject: ["communities": enriched], options: [.sortedKeys]) + let destination = container.appendingPathComponent("push-communities.json") + try data.write(to: destination, options: [.atomic]) + pushPresentationCacheBridge.retainCommunities( + Set(enriched.compactMap { $0["id"] as? String }) + ) + } + + static func pushRelayMetadataPubkey( + relayURL: String, + grants: [BuzzPushEndpointGrantRecord] + ) -> String? { + guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else { + return nil + } + return grants.filter { + $0.appProfile == BuzzDevPushEnrollmentDriver.appProfile + && BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin + }.max { + $0.generation < $1.generation + }?.relayMetadataPubkey + } + #endif + private func handleMediaUploadMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult @@ -398,8 +724,7 @@ import UserNotifications ) destinationVideo.preferredTransform = sourceVideo.preferredTransform - if - let sourceAudio, + if let sourceAudio, let destinationAudio = composition.addMutableTrack( withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid @@ -521,7 +846,8 @@ import UserNotifications do { let durationSeconds = CMTimeGetSeconds(asset.duration) - let middleTime = durationSeconds.isFinite && durationSeconds > 0 + let middleTime = + durationSeconds.isFinite && durationSeconds > 0 ? min(durationSeconds / 2, 1) : 0 let candidateTimes = [0, 0.1, middleTime] @@ -541,11 +867,12 @@ import UserNotifications } guard let posterImage else { - throw lastError ?? NSError( - domain: "BuzzVideoPoster", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Unable to decode a video frame."] - ) + throw lastError + ?? NSError( + domain: "BuzzVideoPoster", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unable to decode a video frame."] + ) } guard let jpegData = try MediaSanitizer.encodeJpeg(UIImage(cgImage: posterImage)) else { throw NSError( @@ -641,3 +968,15 @@ import UserNotifications ) } } + +#if BUZZ_PUSH_ENABLED + extension BuzzPushNavigationTarget { + fileprivate var flutterArguments: [String: String] { + [ + "eventId": eventID, + "communityId": communityID, + "channelId": channelID, + ] + } + } +#endif diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 80292ff8bb7..baf248cfa5b 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -2,6 +2,10 @@ + BuzzAppGroupIdentifier + $(BUZZ_APP_GROUP_IDENTIFIER) + BuzzKeychainAccessGroup + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift new file mode 100644 index 00000000000..ca4c6ace787 --- /dev/null +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -0,0 +1,108 @@ +#if BUZZ_PUSH_ENABLED + import BuzzPushKit + import Foundation + import Security + + /// Keychain-backed endpoint grant storage. The opaque grant is never written to + /// 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 account = "v1" + + private let accessGroup: String? + + init(accessGroup: String?) { + self.accessGroup = accessGroup + } + + func records() throws -> [BuzzPushEndpointGrantRecord] { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess, let data = result as? Data else { + throw keychainError(status, operation: "read") + } + do { + return try JSONDecoder().decode([BuzzPushEndpointGrantRecord].self, from: data) + } catch { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Stored endpoint grants are invalid: \(error)"] + ) + } + } + + func save(_ record: BuzzPushEndpointGrantRecord) throws { + var all = try records() + all.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + all.append(record) + try replace(all) + } + + private func replace(_ records: [BuzzPushEndpointGrantRecord]) throws { + let data = try JSONEncoder().encode(records) + let updateStatus = SecItemUpdate( + baseQuery() as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw keychainError(updateStatus, operation: "update") + } + + var add = baseQuery() + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw keychainError(addStatus, operation: "add") + } + } + + private func baseQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } + + private func keychainError(_ status: OSStatus, operation: String) -> Error { + NSError( + domain: NSOSStatusErrorDomain, + code: Int(status), + userInfo: [ + NSLocalizedDescriptionKey: + "Endpoint grant Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)" + ] + ) + } + } + + extension BuzzPushEndpointGrantRecord { + var flutterArguments: [String: Any] { + let arguments: [String: Any] = [ + "relayOrigin": relayOrigin, + "relayPubkey": relayPubkey, + "installationId": installationId, + "endpointGrant": endpointGrant, + "endpointHash": endpointHash, + "appProfile": appProfile, + "endpointEpoch": endpointEpoch, + "generation": generation, + "expiresAt": expiresAt, + ] + return arguments + } + } +#endif diff --git a/mobile/ios/Runner/PushNativeState.swift b/mobile/ios/Runner/PushNativeState.swift new file mode 100644 index 00000000000..1b216855661 --- /dev/null +++ b/mobile/ios/Runner/PushNativeState.swift @@ -0,0 +1,79 @@ +#if BUZZ_PUSH_ENABLED + import BuzzPushKit + import Foundation + import Security + import UserNotifications + +final class BuzzOneShotCompletion { + private let lock = NSLock() + private var completion: (() -> Void)? + + init(_ completion: @escaping () -> Void) { + self.completion = completion + } + + func call() { + lock.lock() + let completion = completion + self.completion = nil + lock.unlock() + completion?() + } +} + +enum BuzzPushNotificationResponseCoordinator { + static func handle( + actionIdentifier: String, + userInfo: [AnyHashable: Any], + onTarget: (BuzzPushNavigationTarget) -> Void, + forwardToFlutter: (@escaping () -> Void) -> Void, + completion: @escaping () -> Void + ) { + let completionGate = BuzzOneShotCompletion(completion) + defer { completionGate.call() } + + if actionIdentifier == UNNotificationDefaultActionIdentifier, + let target = BuzzPushNavigationTarget.decodeIfPresent(from: userInfo) + { + onTarget(target) + } + forwardToFlutter { completionGate.call() } + } +} + +enum BuzzPushKeychain { + static let service = "buzz.push.nse.signing" + + static func replace(signingKeys: [String: String], accessGroup: String?) throws { + var query = baseQuery(accessGroup: accessGroup) + SecItemDelete(query as CFDictionary) + for (communityID, privateKeyHex) in signingKeys { + query[kSecAttrAccount as String] = communityID + query[kSecValueData as String] = Data(privateKeyHex.utf8) + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + SecItemDelete(baseQuery(accessGroup: accessGroup) as CFDictionary) + throw NSError( + domain: NSOSStatusErrorDomain, code: Int(status), + userInfo: [NSLocalizedDescriptionKey: SecCopyErrorMessageString(status, nil) ?? "Keychain write failed" as CFString] + ) + } + query.removeValue(forKey: kSecValueData as String) + query.removeValue(forKey: kSecAttrAccessible as String) + query.removeValue(forKey: kSecAttrAccount as String) + } + } + + private static func baseQuery(accessGroup: String?) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } +} +#endif diff --git a/mobile/ios/Runner/PushPresentationCacheBridge.swift b/mobile/ios/Runner/PushPresentationCacheBridge.swift new file mode 100644 index 00000000000..b7493491326 --- /dev/null +++ b/mobile/ios/Runner/PushPresentationCacheBridge.swift @@ -0,0 +1,199 @@ +#if BUZZ_PUSH_ENABLED + import BuzzPushKit + import Flutter + import Foundation + +final class BuzzPushPresentationCacheBridge { + private let appGroupIdentifier: String? + private let queue = DispatchQueue( + label: "xyz.block.buzz.push-presentation-cache", + qos: .utility + ) + private lazy var store: BuzzPushPresentationCacheStore? = { + guard let appGroupIdentifier, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + else { return nil } + return BuzzPushPresentationCacheStore(containerURL: container) + }() + + init(appGroupIdentifier: String?) { + self.appGroupIdentifier = appGroupIdentifier + } + + @discardableResult + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) -> Bool { + switch call.method { + case "cachePresentationProfiles": + cacheProfiles(call.arguments, result: result) + case "cachePresentationChannels": + cacheChannels(call.arguments, result: result) + case "cachePresentationAvatar": + cacheAvatar(call.arguments, result: result) + default: + return false + } + return true + } + + func retainCommunities(_ communityIDs: Set) { + queue.async { [weak self] in + try? self?.store?.retainCommunities(communityIDs) + } + } + + private func cacheProfiles(_ rawArguments: Any?, result: @escaping FlutterResult) { + guard let arguments = rawArguments as? [String: Any], + let communityID = arguments["communityId"] as? String, + let rawEvents = arguments["events"] as? [[String: Any]], + rawEvents.count <= BuzzPushPresentationCacheStore.maximumProfiles + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected communityId and profile events.", + details: nil + ) + ) + return + } + queue.async { [weak self] in + do { + guard let self, let community = community(id: communityID) else { + Self.complete(result, value: nil) + return + } + let events = try decodeEvents(rawEvents) + try store?.updateProfiles( + communityID: communityID, + relayOrigin: community.relayUrl, + updates: events.map { BuzzPushProfileCacheUpdate(event: $0) } + ) + Self.complete(result, value: nil) + } catch { + Self.complete( + result, + value: FlutterError( + code: "profile_cache_failed", + message: "Unable to cache push sender profiles.", + details: error.localizedDescription + ) + ) + } + } + } + + private func cacheChannels(_ rawArguments: Any?, result: @escaping FlutterResult) { + guard let arguments = rawArguments as? [String: Any], + let communityID = arguments["communityId"] as? String, + let rawMetadataEvents = arguments["metadataEvents"] as? [[String: Any]], + let rawMembershipEvents = arguments["membershipEvents"] as? [[String: Any]], + rawMetadataEvents.count <= BuzzPushPresentationCacheStore.maximumChannels, + rawMembershipEvents.count <= BuzzPushPresentationCacheStore.maximumChannels + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected communityId, channel metadata, and membership events.", + details: nil + ) + ) + return + } + queue.async { [weak self] in + do { + guard let self, let community = community(id: communityID), + let relayMetadataPubkey = community.relayMetadataPubkey + else { + Self.complete(result, value: nil) + return + } + try store?.updateChannels( + communityID: communityID, + relayOrigin: community.relayUrl, + relayMetadataPubkey: relayMetadataPubkey, + metadataEvents: try decodeEvents(rawMetadataEvents), + membershipEvents: try decodeEvents(rawMembershipEvents) + ) + Self.complete(result, value: nil) + } catch { + Self.complete( + result, + value: FlutterError( + code: "channel_cache_failed", + message: "Unable to cache push channel metadata.", + details: error.localizedDescription + ) + ) + } + } + } + + private func cacheAvatar(_ rawArguments: Any?, result: @escaping FlutterResult) { + guard let arguments = rawArguments as? [String: Any], + let communityID = arguments["communityId"] as? String, + let sourceURL = arguments["sourceUrl"] as? String, + let avatar = arguments["png"] as? FlutterStandardTypedData + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected an avatar source and PNG thumbnail.", + details: nil + ) + ) + return + } + let avatarData = avatar.data + queue.async { [weak self] in + do { + guard let self, let community = community(id: communityID) else { + Self.complete(result, value: false) + return + } + let updated = try store?.updateAvatar( + communityID: communityID, + relayOrigin: community.relayUrl, + sourceURL: sourceURL, + avatarPNG: avatarData + ) ?? false + Self.complete(result, value: updated) + } catch { + Self.complete( + result, + value: FlutterError( + code: "avatar_cache_failed", + message: "Unable to cache a push sender avatar.", + details: error.localizedDescription + ) + ) + } + } + } + + private func community(id: String) -> PushLeaseCommunity? { + guard let appGroupIdentifier, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ), + let data = try? Data( + contentsOf: container.appendingPathComponent("push-communities.json") + ), + let snapshot = try? JSONDecoder().decode(PushLeaseSnapshot.self, from: data) + else { return nil } + return snapshot.communities.first { $0.id == id } + } + + private func decodeEvents(_ rawEvents: [[String: Any]]) throws -> [VerifiedNostrEvent] { + let data = try JSONSerialization.data(withJSONObject: rawEvents) + return try JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + } + + private static func complete(_ result: @escaping FlutterResult, value: Any?) { + DispatchQueue.main.async { + result(value) + } + } +} +#endif diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements new file mode 100644 index 00000000000..0c67376ebac --- /dev/null +++ b/mobile/ios/Runner/Runner.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/ios/Runner/RunnerPush-Info.plist b/mobile/ios/Runner/RunnerPush-Info.plist new file mode 100644 index 00000000000..998cc20a3d5 --- /dev/null +++ b/mobile/ios/Runner/RunnerPush-Info.plist @@ -0,0 +1,98 @@ + + + + + BuzzAppGroupIdentifier + $(BUZZ_APP_GROUP_IDENTIFIER) + BuzzKeychainAccessGroup + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(APP_DISPLAY_NAME) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Buzz + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.buzz.deeplink + CFBundleURLSchemes + + buzz + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FlutterDeepLinkingEnabled + + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + NSFaceIDUsageDescription + Buzz uses Face ID to confirm sensitive identity transfers. + NSCameraUsageDescription + Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing. + NSPhotoLibraryUsageDescription + Buzz needs photo library access so you can attach images to messages. + NSPhotoLibraryAddUsageDescription + Buzz needs permission to save images to your photo library. + NSUserActivityTypes + + INSendMessageIntent + + PHPhotoLibraryPreventAutomaticLimitedAccessAlert + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/mobile/ios/Runner/RunnerPush.entitlements b/mobile/ios/Runner/RunnerPush.entitlements new file mode 100644 index 00000000000..7fca08a0f35 --- /dev/null +++ b/mobile/ios/Runner/RunnerPush.entitlements @@ -0,0 +1,20 @@ + + + + + aps-environment + $(BUZZ_IOS_PUSH_ENVIRONMENT) + com.apple.developer.devicecheck.appattest-environment + $(BUZZ_APP_ATTEST_ENVIRONMENT) + com.apple.developer.usernotifications.communication + + com.apple.security.application-groups + + $(BUZZ_APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + + + diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift new file mode 100644 index 00000000000..098067cc90d --- /dev/null +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -0,0 +1,280 @@ +import BuzzPushKit +import Intents +import UserNotifications +import XCTest + +@testable import Buzz + +final class BuzzCommunicationNotificationTests: XCTestCase { + func testIntentUsesVerifiedSenderAvatarAndGroupName() throws { + let resolution = communicationResolution( + displayName: "Alice", + groupName: "General", + avatarPNG: Data([0x89, 0x50, 0x4E, 0x47]) + ) + let descriptor = try XCTUnwrap( + BuzzCommunicationNotificationDescriptor(resolution: resolution) + ) + + let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor) + + XCTAssertEqual(intent.sender?.displayName, "Alice") + XCTAssertEqual(intent.sender?.customIdentifier, descriptor.senderIdentifier) + XCTAssertNotNil(intent.sender?.image) + XCTAssertEqual(intent.content, "Hello Buzz") + XCTAssertEqual(intent.speakableGroupName?.spokenPhrase, "General") + XCTAssertEqual(intent.conversationIdentifier, resolution.conversationIdentifier) + XCTAssertNil(intent.recipients) + XCTAssertEqual(descriptor.recipientCount, 1) + XCTAssertEqual( + (intent.donationMetadata as? INSendMessageIntentDonationMetadata)?.recipientCount, + 1 + ) + } + + func testDirectMessageRetainsSenderAvatarWithoutGroupMetadata() throws { + let resolution = communicationResolution( + displayName: "Alice", + groupName: nil, + avatarPNG: Data([0x89, 0x50, 0x4E, 0x47]) + ) + let descriptor = try XCTUnwrap( + BuzzCommunicationNotificationDescriptor(resolution: resolution) + ) + + let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor) + + XCTAssertEqual(intent.sender?.displayName, "Alice") + XCTAssertNotNil(intent.sender?.image) + XCTAssertNil(intent.speakableGroupName) + XCTAssertNil(intent.donationMetadata) + } + + func testMissingVerifiedRecipientCountUsesOrdinaryPresentation() { + XCTAssertNil( + BuzzCommunicationNotificationDescriptor( + resolution: communicationResolution(recipientCount: nil) + ) + ) + } + + func testPresentationFallsBackWhenDonationFails() { + let ordinary = UNMutableNotificationContent() + ordinary.title = "Alice" + ordinary.body = "Hello Buzz" + var updateCalled = false + let presenter = BuzzCommunicationNotificationPresenter( + donate: { _, completion in + completion(NSError(domain: "test", code: 1)) + }, + updateContent: { content, _ in + updateCalled = true + return content + } + ) + let completed = expectation(description: "ordinary fallback returned") + + presenter.present( + ordinaryContent: ordinary, + resolution: communicationResolution() + ) { content in + XCTAssertEqual(content.title, "Alice") + XCTAssertEqual(content.body, "Hello Buzz") + completed.fulfill() + } + + wait(for: [completed], timeout: 1) + XCTAssertFalse(updateCalled) + } + + func testPresentationDonatesBeforeSpecializing() { + let ordinary = UNMutableNotificationContent() + ordinary.title = "Alice" + var order: [String] = [] + let presenter = BuzzCommunicationNotificationPresenter( + donate: { _, completion in + order.append("donate") + completion(nil) + }, + updateContent: { _, _ in + order.append("update") + let specialized = UNMutableNotificationContent() + specialized.title = "specialized" + return specialized + } + ) + let completed = expectation(description: "specialized content returned") + + presenter.present( + ordinaryContent: ordinary, + resolution: communicationResolution() + ) { content in + XCTAssertEqual(content.title, "specialized") + completed.fulfill() + } + + wait(for: [completed], timeout: 1) + XCTAssertEqual(order, ["donate", "update"]) + } + + func testPresentationFallsBackWhenSpecializationFails() { + let ordinary = UNMutableNotificationContent() + ordinary.title = "Alice" + ordinary.body = "Hello Buzz" + let presenter = BuzzCommunicationNotificationPresenter( + donate: { _, completion in completion(nil) }, + updateContent: { _, _ in throw NSError(domain: "test", code: 2) } + ) + let completed = expectation(description: "ordinary fallback returned") + + presenter.present( + ordinaryContent: ordinary, + resolution: communicationResolution() + ) { content in + XCTAssertEqual(content.title, "Alice") + XCTAssertEqual(content.body, "Hello Buzz") + completed.fulfill() + } + + wait(for: [completed], timeout: 1) + } + + private func communicationResolution( + displayName: String = "Alice", + groupName: String? = "General", + avatarPNG: Data? = nil, + recipientCount: Int? = 1 + ) -> BuzzPushResolution { + let communityID = "community-id" + let channelID = "channel/general:v5" + return BuzzPushResolution( + title: displayName, + body: "Hello Buzz", + subtitle: "Community", + threadIdentifier: BuzzPushPresentationIdentity.conversation( + communityID: communityID, + channelID: channelID + ), + navigationTarget: BuzzPushNavigationTarget( + eventID: "message-id", + communityID: communityID, + channelID: channelID + ), + senderPubkey: String(repeating: "a", count: 64), + senderAvatarPNG: avatarPNG, + conversationIdentifier: BuzzPushPresentationIdentity.conversation( + communityID: communityID, + channelID: channelID + ), + conversationDisplayName: groupName, + conversationRecipientCount: recipientCount + ) + } +} + +final class BuzzPushSnapshotEnrichmentTests: XCTestCase { + func testMetadataAuthorityUsesCurrentAppProfileForMatchingRelay() { + let correctProfile = grant( + appProfile: BuzzDevPushEnrollmentDriver.appProfile, + generation: 2, + metadataPubkey: String(repeating: "a", count: 64) + ) + let wrongProfile = grant( + appProfile: "buzz-ios-app-store", + generation: 99, + metadataPubkey: String(repeating: "b", count: 64) + ) + + XCTAssertEqual( + AppDelegate.pushRelayMetadataPubkey( + relayURL: "wss://relay.example/", + grants: [wrongProfile, correctProfile] + ), + correctProfile.relayMetadataPubkey + ) + } + + private func grant( + appProfile: String, + generation: Int64, + metadataPubkey: String + ) -> BuzzPushEndpointGrantRecord { + BuzzPushEndpointGrantRecord( + relayOrigin: "https://relay.example", + relayPubkey: String(repeating: "c", count: 64), + relayMetadataPubkey: metadataPubkey, + installationId: "installation", + endpointGrant: "opaque-grant", + endpointHash: String(repeating: "d", count: 64), + appProfile: appProfile, + endpointEpoch: 1, + generation: generation, + expiresAt: 1_900_000_000 + ) + } +} + +final class BuzzPushNotificationResponseTests: XCTestCase { + func testValidDefaultActionRoutesAndCompletesExactlyOnce() { + let target = BuzzPushNavigationTarget( + eventID: "message-id", + communityID: "community-id", + channelID: "opaque-channel-id" + ) + var routedTargets: [BuzzPushNavigationTarget] = [] + var forwarded = 0 + var completions = 0 + + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: UNNotificationDefaultActionIdentifier, + userInfo: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue], + onTarget: { routedTargets.append($0) }, + forwardToFlutter: { pluginCompletion in + forwarded += 1 + pluginCompletion() + pluginCompletion() + }, + completion: { completions += 1 } + ) + + XCTAssertEqual(routedTargets, [target]) + XCTAssertEqual(forwarded, 1) + XCTAssertEqual(completions, 1) + } + + func testMalformedTargetFallsBackToOneCompletion() { + var routedTargets: [BuzzPushNavigationTarget] = [] + var completions = 0 + + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: UNNotificationDefaultActionIdentifier, + userInfo: [BuzzPushNavigationTarget.userInfoKey: ["event_id": ""]], + onTarget: { routedTargets.append($0) }, + forwardToFlutter: { _ in }, + completion: { completions += 1 } + ) + + XCTAssertTrue(routedTargets.isEmpty) + XCTAssertEqual(completions, 1) + } + + func testNonDefaultActionIgnoresLateDuplicatePluginCompletion() throws { + var routedTargets: [BuzzPushNavigationTarget] = [] + var pluginCompletion: (() -> Void)? + var completions = 0 + + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: "buzz.reply", + userInfo: [:], + onTarget: { routedTargets.append($0) }, + forwardToFlutter: { pluginCompletion = $0 }, + completion: { completions += 1 } + ) + let capturedCompletion = try XCTUnwrap(pluginCompletion) + capturedCompletion() + capturedCompletion() + + XCTAssertTrue(routedTargets.isEmpty) + XCTAssertEqual(completions, 1) + } +} diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index f2726b9f13a..a968302b3e1 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -19,6 +19,8 @@ import 'features/settings/settings_page.dart'; import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/emoji/emoji_burst.dart'; +import 'shared/push/push_subscription_provider.dart'; +import 'shared/push/push_capability.dart'; import 'shared/relay/relay.dart'; import 'shared/read_state/read_state_provider.dart'; import 'shared/theme/theme.dart'; @@ -87,6 +89,9 @@ class App extends HookConsumerWidget { ref.watch(observerRelayProvider); ref.watch(appLifecycleProvider); ref.watch(userStatusCacheProvider); + if (buzzPushCapabilityEnabled) { + ref.watch(pushSubscriptionSyncProvider); + } hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0; } diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 2f8dddf2d92..980d8b571b4 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -5,6 +5,8 @@ import 'dart:math'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/community/community_provider.dart'; +import '../../shared/push/push_presentation_cache.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme_provider.dart'; import '../../shared/utils/string_utils.dart'; @@ -139,6 +141,7 @@ class ChannelsNotifier extends AsyncNotifier> { }) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) throw StateError('No signing identity available'); + final communityID = ref.read(activeCommunityProvider).value?.id; _loadThreadInterestStores(myPk); final session = ref.read(relaySessionProvider.notifier); @@ -190,11 +193,14 @@ class ChannelsNotifier extends AsyncNotifier> { final id = event.getTagValue('d'); if (id == null) continue; final existing = latestMetaPerId[id]; - if (existing == null || event.createdAt > existing.createdAt) { + if (existing == null || + event.createdAt > existing.createdAt || + (event.createdAt == existing.createdAt && + event.id.compareTo(existing.id) < 0)) { latestMetaPerId[id] = event; } } - final dedupedMetas = latestMetaPerId.values; + final dedupedMetas = latestMetaPerId.values.toList(); // Resolve DM participant display names. Relay stores DM channels with // literal name="DM"; pure-Nostr architecture pushes name resolution to @@ -254,8 +260,31 @@ class ChannelsNotifier extends AsyncNotifier> { ), ); if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); + final latestMembershipPerId = {}; + for (final event in [...memberships, ...memberEvents]) { + if (event.kind != 39002) continue; + final id = event.getTagValue('d'); + if (id == null) continue; + final existing = latestMembershipPerId[id]; + if (existing == null || + event.createdAt > existing.createdAt || + (event.createdAt == existing.createdAt && + event.id.compareTo(existing.id) < 0)) { + latestMembershipPerId[id] = event; + } + } + final dedupedMemberships = latestMembershipPerId.values.toList(); + if (communityID != null) { + unawaited( + cacheBuzzPushChannelEvents( + communityID, + dedupedMetas, + dedupedMemberships, + ), + ); + } final memberCounts = {}; - for (final event in memberEvents) { + for (final event in dedupedMemberships) { final chId = event.getTagValue('d'); if (chId == null) continue; final pTags = {}; diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 391a4f1a495..56e86a53f03 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -3,6 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/deeplink/pending_deep_link_provider.dart'; +import '../../shared/community/community.dart'; +import '../../shared/community/community_provider.dart'; import '../invites/invite_join_provider.dart'; import '../invites/invite_join_sheet.dart'; import 'channel.dart'; @@ -37,6 +39,7 @@ class DeepLinkDispatcher extends ConsumerStatefulWidget { class _DeepLinkDispatcherState extends ConsumerState { bool _preparingInvite = false; + String? _switchingCommunityId; @override void initState() { @@ -57,6 +60,12 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.listen>>(channelsProvider, (_, _) { _maybeDispatch(ref.read(pendingDeepLinkProvider)); }); + ref.listen>(activeCommunityProvider, (_, _) { + _maybeDispatch(ref.read(pendingDeepLinkProvider)); + }); + ref.listen>>(communityListProvider, (_, _) { + _maybeDispatch(ref.read(pendingDeepLinkProvider)); + }); } return widget.child; @@ -72,6 +81,7 @@ class _DeepLinkDispatcherState extends ConsumerState { !widget.dispatchMessageLinks) { return; } + if (link is MessageDeepLink && !_prepareNotificationCommunity(link)) return; final channelId = switch (link) { MessageDeepLink(:final channelId) => channelId, @@ -115,6 +125,59 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.read(pendingDeepLinkProvider.notifier).consume(); } + bool _prepareNotificationCommunity(MessageDeepLink link) { + final communityId = link.communityId; + if (communityId == null) return true; + + final communities = ref.read(communityListProvider).asData?.value; + if (communities == null) return false; + if (!communities.any((community) => community.id == communityId)) { + ref.read(pendingDeepLinkProvider.notifier).consume(); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Notification community is no longer available'), + ), + ); + return false; + } + + final activeCommunity = ref.read(activeCommunityProvider).asData?.value; + if (activeCommunity?.id == communityId) return true; + _switchCommunity(communityId); + return false; + } + + void _switchCommunity(String communityId) { + if (_switchingCommunityId != null) return; + _switchingCommunityId = communityId; + Future.microtask(() async { + var switched = false; + try { + await ref + .read(communityListProvider.notifier) + .switchCommunity(communityId); + switched = true; + } catch (error) { + debugPrint( + 'notification-routing: failed to switch to community ' + '$communityId: $error', + ); + if (mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Could not open the notification community'), + ), + ); + } + } finally { + _switchingCommunityId = null; + if (mounted && switched) { + _maybeDispatch(ref.read(pendingDeepLinkProvider)); + } + } + }); + } + void _maybeDispatchInvite(InviteDeepLink link) { if (_preparingInvite) return; _preparingInvite = true; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 833d48b207c..fb8ce50aebb 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -3,10 +3,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; +import 'shared/push/push_bootstrap.dart'; +import 'shared/push/push_capability.dart'; +import 'shared/push/push_bridge.dart'; import 'shared/theme/theme_provider.dart'; -void main() async { +void main() => runBuzzApp(const App()); + +Future runBuzzApp(Widget app) async { WidgetsFlutterBinding.ensureInitialized(); + if (buzzPushCapabilityEnabled) { + installBuzzPushMethodHandler(); + await syncPendingBuzzPushNotificationResponse(); + } // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); @@ -14,7 +23,7 @@ void main() async { runApp( ProviderScope( overrides: [savedPrefsProvider.overrideWithValue(prefs)], - child: const App(), + child: buzzPushCapabilityEnabled ? BuzzPushBootstrap(child: app) : app, ), ); } diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart index ade2220264a..12d88bdcf75 100644 --- a/mobile/lib/shared/auth/auth_provider.dart +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -24,6 +24,7 @@ class AuthNotifier extends AsyncNotifier { final storage = ref.read(communityStorageProvider); final communities = await storage.loadAll(); if (communities.isEmpty) { + await syncCommunitySnapshot(ref, communities); return const AuthState(status: AuthStatus.unauthenticated); } @@ -36,6 +37,7 @@ class AuthNotifier extends AsyncNotifier { await storage.saveActiveId(active.id); if (_hasValidNsec(active.nsec)) { + await syncCommunitySnapshot(ref, communities); return AuthState(status: AuthStatus.authenticated, community: active); } @@ -47,6 +49,7 @@ class AuthNotifier extends AsyncNotifier { } await storage.clearActiveId(); + await syncCommunitySnapshot(ref, communities); return const AuthState(status: AuthStatus.unauthenticated); } @@ -57,6 +60,7 @@ class AuthNotifier extends AsyncNotifier { final storage = ref.read(communityStorageProvider); await storage.save(community); await storage.saveActiveId(community.id); + await syncStoredCommunitySnapshot(ref); // Invalidate community providers so other consumers pick up the new data. ref.invalidate(communityListProvider); @@ -71,13 +75,24 @@ class AuthNotifier extends AsyncNotifier { final storage = ref.read(communityStorageProvider); final activeId = await storage.loadActiveId(); if (activeId != null) { + final communities = await storage.loadAll(); + final activeIndex = communities.indexWhere( + (community) => community.id == activeId, + ); + if (activeIndex >= 0) { + await ref.read(communityPushLeaseDeactivatorProvider)( + communities[activeIndex], + ); + } await storage.remove(activeId); await storage.clearActiveId(); } // Check if other communities remain — switch to the next one instead of - // forcing the user back to the pairing screen. + // forcing the user back to the pairing screen. Refreshing the complete + // snapshot also removes revoked keys from the extension Keychain. final remaining = await storage.loadAll(); + await syncCommunitySnapshot(ref, remaining); // Invalidate community providers so other consumers pick up the change. ref.invalidate(communityListProvider); diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index 409de10e399..161c5dbd19e 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -1,5 +1,7 @@ import 'package:uuid/uuid.dart'; +import '../push/push_subscription.dart'; + const _uuid = Uuid(); const _sentinel = Object(); @@ -12,6 +14,7 @@ class Community { final String? pubkey; final String? nsec; final SensitiveActionPolicy sensitiveActionPolicy; + final BuzzPushLeaseSubscriptionState pushSubscriptionState; final DateTime addedAt; const Community({ @@ -21,6 +24,7 @@ class Community { this.pubkey, this.nsec, this.sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser, + this.pushSubscriptionState = const BuzzPushLeaseSubscriptionState.desired(), required this.addedAt, }); @@ -49,6 +53,7 @@ class Community { Object? pubkey = _sentinel, Object? nsec = _sentinel, SensitiveActionPolicy? sensitiveActionPolicy, + BuzzPushLeaseSubscriptionState? pushSubscriptionState, }) { return Community( id: id, @@ -58,6 +63,8 @@ class Community { nsec: nsec == _sentinel ? this.nsec : nsec as String?, sensitiveActionPolicy: sensitiveActionPolicy ?? this.sensitiveActionPolicy, + pushSubscriptionState: + pushSubscriptionState ?? this.pushSubscriptionState, addedAt: addedAt, ); } @@ -69,6 +76,7 @@ class Community { if (pubkey != null) 'pubkey': pubkey, if (nsec != null) 'nsec': nsec, 'sensitiveActionPolicy': sensitiveActionPolicy.name, + 'pushSubscriptionState': pushSubscriptionState.toJson(), 'addedAt': addedAt.toIso8601String(), }; @@ -82,6 +90,11 @@ class Community { (value) => value.name == json['sensitiveActionPolicy'], orElse: () => SensitiveActionPolicy.disabledByUser, ), + pushSubscriptionState: json['pushSubscriptionState'] == null + ? const BuzzPushLeaseSubscriptionState.desired() + : BuzzPushLeaseSubscriptionState.fromJson( + Map.from(json['pushSubscriptionState'] as Map), + ), addedAt: DateTime.parse(json['addedAt'] as String), ); diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index d014c23b505..ceb2545cbab 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -1,6 +1,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; import '../auth/auth_provider.dart'; +import '../push/dev_push_lease.dart'; +import '../push/push_bridge.dart'; +import '../push/push_capability.dart'; +import '../push/push_subscription.dart'; +import '../relay/signed_event_relay.dart'; import 'community.dart'; import 'community_storage.dart'; @@ -8,11 +14,131 @@ final communityStorageProvider = Provider((ref) { return CommunityStorage(); }); +typedef CommunitySnapshotWriter = + Future Function(List communities); + +/// Writes the complete persisted community set to storage shared with the iOS +/// notification service extension. Tests override this provider to verify that +/// every persistence path refreshes (or clears) the native snapshot. +final communitySnapshotWriterProvider = Provider(( + ref, +) { + return registerBuzzPushCommunitySnapshot; +}); + +final _communitySnapshotSyncProvider = Provider<_CommunitySnapshotSync>((ref) { + return _CommunitySnapshotSync(ref.read(communitySnapshotWriterProvider)); +}); + +typedef CommunityPushLeaseDeactivator = + Future Function(Community community); + +final communityPushLeaseDeactivatorProvider = + Provider((ref) { + return (community) => _deactivateCommunityPushLease(community); + }); + +Future _deactivateCommunityPushLease(Community community) async { + if (!buzzPushCapabilityEnabled) return; + final state = community.pushSubscriptionState; + final acceptedGeneration = state.acceptedGeneration; + final installationId = state.acceptedInstallationId; + final nsec = community.nsec; + if (acceptedGeneration == null || + installationId == null || + nsec == null || + nsec.isEmpty) { + return; + } + try { + final decoded = nostr.Nip19.decode(payload: nsec); + final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public; + final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl); + final uri = Uri.parse(community.relayUrl); + final httpScheme = switch (uri.scheme) { + 'wss' => 'https', + 'ws' => 'http', + _ => uri.scheme, + }; + final wsScheme = httpScheme == 'https' ? 'wss' : 'ws'; + final wsUrl = uri.replace(scheme: wsScheme).toString(); + // Skip over the one renewal generation that could already be in flight + // when removal begins. Strict relay monotonicity then makes any stale + // active publication lose to this tombstone. + final generation = acceptedGeneration + 2; + await publishBuzzPushLeaseTombstone( + descriptor: descriptor, + installationId: installationId, + generation: generation, + nsec: nsec, + memberPubkey: memberPubkey, + submit: ({required kind, required content, required tags, createdAt}) => + submitSignedEventOnce( + wsUrl: wsUrl, + nsec: nsec, + kind: kind, + content: content, + tags: tags, + createdAt: createdAt, + ), + ); + pushLeaseCleanupError.value = null; + } catch (error, stackTrace) { + // Community removal remains local-first. A failed best-effort tombstone is + // observable here and the already-bounded relay lease expires naturally. + reportPushLeaseCleanupError(error, stackTrace); + } +} + +class _CommunitySnapshotSync { + _CommunitySnapshotSync(this._writer); + + final CommunitySnapshotWriter _writer; + String? _lastSuccessfulSnapshot; + + Future write(List communities) async { + final fingerprint = communities + .map( + (community) => [ + community.id, + community.name, + community.relayUrl, + community.pubkey, + community.nsec, + buzzPushSubscriptionStateFingerprint( + community.pushSubscriptionState, + ), + ].join('\u0000'), + ) + .join('\u0001'); + if (fingerprint == _lastSuccessfulSnapshot) return; + + await _writer(communities); + _lastSuccessfulSnapshot = fingerprint; + } +} + +Future syncCommunitySnapshot(Ref ref, List communities) async { + try { + await ref.read(_communitySnapshotSyncProvider).write(communities); + pushCommunitySnapshotError.value = null; + } catch (error, stackTrace) { + reportPushCommunitySnapshotError(error, stackTrace); + } +} + +Future syncStoredCommunitySnapshot(Ref ref) async { + final communities = await ref.read(communityStorageProvider).loadAll(); + await syncCommunitySnapshot(ref, communities); +} + class CommunityListNotifier extends AsyncNotifier> { @override Future> build() async { final storage = ref.read(communityStorageProvider); - return storage.loadAll(); + final communities = await storage.loadAll(); + await syncCommunitySnapshot(ref, communities); + return communities; } /// Add a community. If one with the same relay URL already exists, update @@ -36,20 +162,31 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]; updatedList[existingIndex] = updated; state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); return existing.id; } await storage.save(community); - state = AsyncData([...current, community]); + final updatedList = [...current, community]; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); return community.id; } Future removeCommunity(String id) async { final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final removedIndex = current.indexWhere((community) => community.id == id); + if (removedIndex >= 0) { + await ref.read(communityPushLeaseDeactivatorProvider)( + current[removedIndex], + ); + } await storage.remove(id); - final current = state.value ?? []; - state = AsyncData(current.where((w) => w.id != id).toList()); + final updatedList = current.where((w) => w.id != id).toList(); + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); // If we removed the active community, switch to another or sign out. final activeId = await storage.loadActiveId(); @@ -78,6 +215,60 @@ class CommunityListNotifier extends AsyncNotifier> { ref.invalidate(authProvider); } + Future updateDesiredPushSubscriptions( + String id, + List desired, + ) async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + + final community = current[index]; + if (buzzPushSubscriptionsFingerprint( + community.pushSubscriptionState.desired, + ) == + buzzPushSubscriptionsFingerprint(desired)) { + return; + } + final updated = community.copyWith( + pushSubscriptionState: community.pushSubscriptionState.withDesired( + desired, + ), + ); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + } + + Future markPushLeaseAccepted( + String id, { + required List subscriptions, + required int generation, + required int grantGeneration, + required String installationId, + }) async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + + final community = current[index]; + final updated = community.copyWith( + pushSubscriptionState: community.pushSubscriptionState.withAccepted( + subscriptions: subscriptions, + generation: generation, + grantGeneration: grantGeneration, + installationId: installationId, + ), + ); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + } + Future renameCommunity(String id, String name) async { final storage = ref.read(communityStorageProvider); final current = state.value ?? []; @@ -90,6 +281,7 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]; updatedList[index] = updated; state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); } } diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart index ca785f9df39..c58acefd218 100644 --- a/mobile/lib/shared/deeplink/deep_link.dart +++ b/mobile/lib/shared/deeplink/deep_link.dart @@ -72,6 +72,10 @@ class ChannelDeepLink extends BuzzDeepLink { /// A parsed `buzz://message` deep link. class MessageDeepLink extends BuzzDeepLink { + /// Local community identifier for notification-originated links. + /// Canonical shared links omit this because community IDs are device-local. + final String? communityId; + /// Channel UUID from the `channel` query param. final String channelId; @@ -82,6 +86,7 @@ class MessageDeepLink extends BuzzDeepLink { final String? threadRootId; const MessageDeepLink({ + this.communityId, required this.channelId, required this.messageId, this.threadRootId, @@ -90,16 +95,18 @@ class MessageDeepLink extends BuzzDeepLink { @override bool operator ==(Object other) => other is MessageDeepLink && + other.communityId == communityId && other.channelId == channelId && other.messageId == messageId && other.threadRootId == threadRootId; @override - int get hashCode => Object.hash(channelId, messageId, threadRootId); + int get hashCode => + Object.hash(communityId, channelId, messageId, threadRootId); @override String toString() => - 'MessageDeepLink(channel: $channelId, id: $messageId, ' + 'MessageDeepLink(community: $communityId, channel: $channelId, id: $messageId, ' 'thread: $threadRootId)'; } diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart index 4875d94fcfa..0f8c052c282 100644 --- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart +++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'deep_link.dart'; +import '../push/push_bridge.dart'; /// Holds supported deep links until they can be dispatched. /// @@ -22,17 +23,27 @@ class PendingDeepLinkNotifier extends Notifier { StreamSubscription? _subscription; final Queue _waiting = Queue(); + VoidCallback? _pushNotificationListener; @override BuzzDeepLink? build() { _waiting.clear(); final stream = debugUriStreamOverride ?? AppLinks().uriLinkStream; _subscription = stream.listen(open); + _pushNotificationListener = () { + final link = pendingPushNotificationLink.value; + if (link != null) _enqueue(link); + }; + pendingPushNotificationLink.addListener(_pushNotificationListener!); ref.onDispose(() { _subscription?.cancel(); _subscription = null; + if (_pushNotificationListener case final listener?) { + pendingPushNotificationLink.removeListener(listener); + } + _pushNotificationListener = null; }); - return null; + return pendingPushNotificationLink.value; } /// Parse and park an incoming URI. Unsupported links are ignored loudly. @@ -42,17 +53,24 @@ class PendingDeepLinkNotifier extends Notifier { debugPrint('deep-link: ignoring unsupported link: $uri'); return; } - if (state == null) { - state = link; - } else { - _waiting.addLast(link); - } + _enqueue(link); } /// Acknowledge the current link and expose the next queued link, if any. void consume() { + if (pendingPushNotificationLink.value == state) { + pendingPushNotificationLink.value = null; + } state = _waiting.isEmpty ? null : _waiting.removeFirst(); } + + void _enqueue(BuzzDeepLink link) { + if (state == null) { + state = link; + } else { + _waiting.addLast(link); + } + } } final pendingDeepLinkProvider = diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index 4354cc2b164..4ca1e92b15e 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../community/community_provider.dart'; import '../crypto/nip_oa.dart'; +import '../push/push_presentation_cache.dart'; import '../relay/relay.dart'; import 'user_profile.dart'; @@ -56,6 +58,7 @@ class UserCacheNotifier extends Notifier> { final pubkeys = _pending.toList(); _pending.clear(); + final communityID = ref.read(activeCommunityProvider).value?.id; try { final session = ref.read(relaySessionProvider.notifier); @@ -78,6 +81,9 @@ class UserCacheNotifier extends Notifier> { } state = updated; + if (communityID != null) { + unawaited(cacheBuzzPushProfileEvents(communityID, events)); + } } catch (_) { // Silently fail — we'll just show pubkeys. } diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart new file mode 100644 index 00000000000..2a41c3220d5 --- /dev/null +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -0,0 +1,756 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:nostr/nostr.dart' as nostr; + +import '../crypto/nip44.dart'; +import '../relay/nostr_models.dart'; +import '../relay/signed_event_relay.dart'; +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; +const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$'; +const _installationIdPattern = r'^[0-9a-f]{32}$'; + +class BuzzPushLeaseDescriptor { + final String origin; + final String executorKeyId; + final String executorPubkey; + final String transport; + final int maxLeaseTtlSeconds; + final int maxContentLength; + final int maxPlaintextLength; + final int maxEndpointLength; + final int maxStringLength; + + const BuzzPushLeaseDescriptor({ + required this.origin, + required this.executorKeyId, + required this.executorPubkey, + required this.transport, + required this.maxLeaseTtlSeconds, + required this.maxContentLength, + required this.maxPlaintextLength, + required this.maxEndpointLength, + required this.maxStringLength, + }); + + factory BuzzPushLeaseDescriptor.fromRelayInformation( + Map information, + ) { + _requireExactKeys( + information, + required: const {}, + allowed: const { + 'name', + 'description', + 'pubkey', + 'contact', + 'supported_nips', + 'supported_extensions', + 'software', + 'version', + 'limitation', + 'retention', + 'relay_countries', + 'language_tags', + 'tags', + 'posting_policy', + 'payments_url', + 'fees', + 'icon', + 'self', + 'pairing_relay_url', + 'push', + }, + name: 'NIP-11 document', + ); + final extensions = _stringList( + information['supported_extensions'], + name: 'supported_extensions', + ); + if (!extensions.contains('nip-pl')) { + throw const FormatException('NIP-11 does not advertise nip-pl'); + } + + final push = _stringMap(information['push'], name: 'push'); + _requireExactKeys( + push, + required: const { + 'origin', + 'keys', + 'app_profiles', + 'push_kinds', + 'urgent_kinds', + 'h_grammar', + 'class_support', + 'limitation', + }, + allowed: const { + 'origin', + 'keys', + 'app_profiles', + 'push_kinds', + 'urgent_kinds', + 'h_grammar', + 'class_support', + 'limitation', + }, + name: 'push descriptor', + ); + + final origin = _canonicalOrigin(push['origin']); + final keys = _mapList(push['keys'], name: 'push.keys'); + final keyIds = {}; + final currentKeys = >[]; + for (final key in keys) { + _requireExactKeys( + key, + required: const {'id', 'pubkey'}, + allowed: const {'id', 'pubkey', 'current', 'retiring'}, + name: 'push key', + ); + final id = _nonEmptyString(key['id'], name: 'push key id'); + if (!keyIds.add(id)) { + throw FormatException('Duplicate push key id: $id'); + } + _lowercaseHex64(key['pubkey'], name: 'push key pubkey'); + final current = key['current']; + final retiring = key['retiring']; + if (current != null && current is! bool) { + throw const FormatException('push key current must be a boolean'); + } + if (retiring != null && retiring is! bool) { + throw const FormatException('push key retiring must be a boolean'); + } + if (current == true) currentKeys.add(key); + } + if (currentKeys.length != 1) { + throw const FormatException( + 'push descriptor must contain exactly one current key', + ); + } + 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', + ); + } + + final pushKinds = _intList(push['push_kinds'], name: 'push_kinds'); + if (!buzzPushEligibleKinds.every(pushKinds.contains)) { + throw const FormatException( + 'NIP-11 does not advertise every Buzz message kind for push', + ); + } + final urgentKinds = _intList( + push['urgent_kinds'], + name: 'urgent_kinds', + allowEmpty: true, + ); + if (urgentKinds.any((kind) => !pushKinds.contains(kind))) { + throw const FormatException( + 'urgent_kinds must be a subset of push_kinds', + ); + } + final hGrammar = _nonEmptyString(push['h_grammar'], name: 'h_grammar'); + if (hGrammar != 'uuid-v4-lowercase') { + throw const FormatException('Unsupported push h_grammar'); + } + + final classSupport = _stringMap( + push['class_support'], + name: 'class_support', + ); + final supportedClasses = _stringList( + classSupport[buzzPushTransport], + name: 'class_support.apns', + ); + const knownClasses = {'silent', 'default', 'time_sensitive', 'urgent'}; + if (supportedClasses.any((value) => !knownClasses.contains(value))) { + throw const FormatException('class_support contains an unknown class'); + } + if (!supportedClasses.contains('default')) { + throw const FormatException('APNs does not support the default class'); + } + + final limitation = _stringMap(push['limitation'], name: 'limitation'); + _requireExactKeys( + limitation, + required: const { + 'max_lease_ttl', + 'max_leases_per_pubkey', + 'max_subscriptions_per_lease', + 'max_kinds', + 'max_authors', + 'max_h', + 'max_tag_values', + 'max_ignore', + 'max_content_len', + 'max_plaintext_len', + 'max_endpoint_len', + 'max_string_len', + }, + allowed: const { + 'max_lease_ttl', + 'max_leases_per_pubkey', + 'max_subscriptions_per_lease', + 'max_kinds', + 'max_authors', + 'max_h', + 'max_tag_values', + 'max_ignore', + 'max_content_len', + 'max_plaintext_len', + 'max_endpoint_len', + 'max_string_len', + }, + name: 'push limitation', + ); + for (final entry in limitation.entries) { + _positiveInt(entry.value, name: 'limitation.${entry.key}'); + } + final maxStringLength = limitation['max_string_len'] as int; + _checkStringLength(origin, maxStringLength, name: 'origin'); + _checkStringLength( + currentKey['id'] as String, + maxStringLength, + name: 'push key id', + ); + final maxLeaseTtl = limitation['max_lease_ttl'] as int; + if (maxLeaseTtl > _maxLeaseLifetimeSeconds) { + throw const FormatException('max_lease_ttl exceeds the NIP-PL v1 limit'); + } + + return BuzzPushLeaseDescriptor( + origin: origin, + executorKeyId: currentKey['id'] as String, + executorPubkey: currentKey['pubkey'] as String, + transport: transport!, + maxLeaseTtlSeconds: maxLeaseTtl, + maxContentLength: limitation['max_content_len'] as int, + maxPlaintextLength: limitation['max_plaintext_len'] as int, + maxEndpointLength: limitation['max_endpoint_len'] as int, + maxStringLength: maxStringLength, + ); + } +} + +Future fetchBuzzPushLeaseDescriptor( + String relayBaseUrl, { + http.Client? client, + Duration timeout = const Duration(seconds: 8), +}) async { + final uri = Uri.tryParse(relayBaseUrl); + if (uri == null || + !const {'http', 'https'}.contains(uri.scheme) || + uri.host.isEmpty) { + throw FormatException('Invalid relay HTTP URL: $relayBaseUrl'); + } + final requestUri = uri.resolve('/'); + final ownedClient = client ?? http.Client(); + try { + final response = await ownedClient + .get(requestUri, headers: const {'Accept': 'application/nostr+json'}) + .timeout(timeout); + if (response.statusCode != 200) { + throw StateError( + 'NIP-11 request failed with HTTP ${response.statusCode}: ${response.body}', + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const FormatException('NIP-11 response must be a JSON object'); + } + return BuzzPushLeaseDescriptor.fromRelayInformation(decoded); + } finally { + if (client == null) ownedClient.close(); + } +} + +class BuzzPushLeasePublication { + final String eventId; + final int expiration; + final String plaintext; + + const BuzzPushLeasePublication({ + required this.eventId, + required this.expiration, + required this.plaintext, + }); +} + +typedef BuzzPushLeaseSubmit = + Future Function({ + required int kind, + required String content, + required List> tags, + int? createdAt, + }); + +Future publishBuzzDevPushLease({ + required BuzzPushEndpointGrant grant, + int? leaseGeneration, + required BuzzPushLeaseDescriptor descriptor, + required String nsec, + required String memberPubkey, + required List subscriptions, + required BuzzPushLeaseSubmit submit, + DateTime Function() now = DateTime.now, +}) async { + _validateGrant(grant, descriptor); + final effectiveLeaseGeneration = leaseGeneration ?? grant.generation; + if (effectiveLeaseGeneration <= 0 || + effectiveLeaseGeneration > _maxSafeJsonInteger) { + throw const FormatException('Lease generation is invalid'); + } + final normalizedMemberPubkey = _lowercaseHex64( + memberPubkey, + name: 'member pubkey', + ); + final decoded = nostr.Nip19.decode(payload: nsec); + if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) { + throw const FormatException('Signing key must be a 32-byte nsec'); + } + final signingPubkey = nostr.Keys(decoded.data).public; + if (signingPubkey != normalizedMemberPubkey) { + throw const FormatException( + 'Authenticated signing key does not match the lease member pubkey', + ); + } + + final nowSeconds = now().millisecondsSinceEpoch ~/ 1000; + final expiration = min( + grant.expiresAt, + nowSeconds + descriptor.maxLeaseTtlSeconds, + ); + if (expiration <= nowSeconds) { + throw const FormatException('Endpoint grant is already expired'); + } + + final plaintextMap = { + 'v': 1, + 'origin': descriptor.origin, + 'app_profile': grant.appProfile, + 'transport': descriptor.transport, + 'endpoint': grant.endpointGrant, + 'generation': effectiveLeaseGeneration, + 'active': true, + 'subscriptions': [ + for (final subscription in subscriptions) subscription.toJson(), + ], + }; + validateBuzzPushLeasePlaintext( + plaintextMap, + maxEndpointLength: descriptor.maxEndpointLength, + maxStringLength: descriptor.maxStringLength, + ); + final plaintext = jsonEncode(plaintextMap); + if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) { + throw const FormatException('lease plaintext exceeds the advertised limit'); + } + final conversationKey = getConversationKey( + decoded.data, + descriptor.executorPubkey, + ); + final content = nip44Encrypt(conversationKey, plaintext); + if (utf8.encode(content).length > descriptor.maxContentLength) { + throw const FormatException( + 'lease ciphertext exceeds the advertised limit', + ); + } + final acknowledged = await submit( + kind: buzzPushLeaseKind, + content: content, + tags: [ + ['d', grant.installationId], + ['expiration', '$expiration'], + ['exec', descriptor.executorKeyId], + ], + createdAt: nowSeconds, + ); + if (acknowledged.id.isEmpty) { + throw StateError('Relay returned an empty event id for the push lease'); + } + return BuzzPushLeasePublication( + eventId: acknowledged.id, + expiration: expiration, + plaintext: plaintext, + ); +} + +Future publishBuzzDevPushLeaseThroughRelay({ + required BuzzPushEndpointGrant grant, + int? leaseGeneration, + required BuzzPushLeaseDescriptor descriptor, + required String nsec, + required String memberPubkey, + required List subscriptions, + required SignedEventRelay relay, + DateTime Function() now = DateTime.now, +}) => publishBuzzDevPushLease( + grant: grant, + leaseGeneration: leaseGeneration, + descriptor: descriptor, + nsec: nsec, + memberPubkey: memberPubkey, + subscriptions: subscriptions, + submit: relay.submit, + now: now, +); + +/// Publishes the minimal higher-generation inactive lease used when a +/// community is removed from this device. Gateway delegation is intentionally +/// untouched because it is scoped to the installation and relay key, not the +/// community. +Future publishBuzzPushLeaseTombstone({ + required BuzzPushLeaseDescriptor descriptor, + required String installationId, + required int generation, + required String nsec, + required String memberPubkey, + required BuzzPushLeaseSubmit submit, + DateTime Function() now = DateTime.now, +}) async { + if (!RegExp(_installationIdPattern).hasMatch(installationId)) { + throw const FormatException( + 'Installation id must be 16 random bytes encoded as lowercase hex', + ); + } + if (generation <= 0 || generation > _maxSafeJsonInteger) { + throw const FormatException('Tombstone generation is invalid'); + } + final normalizedMemberPubkey = _lowercaseHex64( + memberPubkey, + name: 'member pubkey', + ); + final decoded = nostr.Nip19.decode(payload: nsec); + if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) { + throw const FormatException('Signing key must be a 32-byte nsec'); + } + if (nostr.Keys(decoded.data).public != normalizedMemberPubkey) { + throw const FormatException( + 'Authenticated signing key does not match the lease member pubkey', + ); + } + + final nowSeconds = now().millisecondsSinceEpoch ~/ 1000; + final expiration = nowSeconds + descriptor.maxLeaseTtlSeconds; + final plaintextMap = { + 'v': 1, + 'origin': descriptor.origin, + 'generation': generation, + 'active': false, + }; + validateBuzzPushLeaseTombstonePlaintext(plaintextMap); + final plaintext = jsonEncode(plaintextMap); + if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) { + throw const FormatException('lease plaintext exceeds the advertised limit'); + } + final content = nip44Encrypt( + getConversationKey(decoded.data, descriptor.executorPubkey), + plaintext, + ); + if (utf8.encode(content).length > descriptor.maxContentLength) { + throw const FormatException( + 'lease ciphertext exceeds the advertised limit', + ); + } + final acknowledged = await submit( + kind: buzzPushLeaseKind, + content: content, + tags: [ + ['d', installationId], + ['expiration', '$expiration'], + ['exec', descriptor.executorKeyId], + ], + createdAt: nowSeconds, + ); + if (acknowledged.id.isEmpty) { + throw StateError('Relay returned an empty event id for the push tombstone'); + } + return BuzzPushLeasePublication( + eventId: acknowledged.id, + expiration: expiration, + plaintext: plaintext, + ); +} + +Future publishBuzzPushLeaseTombstoneThroughRelay({ + required BuzzPushLeaseDescriptor descriptor, + required String installationId, + required int generation, + required String nsec, + required String memberPubkey, + required SignedEventRelay relay, + DateTime Function() now = DateTime.now, +}) => publishBuzzPushLeaseTombstone( + descriptor: descriptor, + installationId: installationId, + generation: generation, + nsec: nsec, + memberPubkey: memberPubkey, + submit: relay.submit, + now: now, +); + +void validateBuzzPushLeaseTombstonePlaintext(Map plaintext) { + _requireExactKeys( + plaintext, + required: const {'v', 'origin', 'generation', 'active'}, + allowed: const {'v', 'origin', 'generation', 'active'}, + name: 'lease tombstone plaintext', + ); + if (plaintext['v'] != 1 || plaintext['active'] != false) { + throw const FormatException('lease tombstone must be inactive v1'); + } + _canonicalOrigin(plaintext['origin']); + final generation = _positiveInt(plaintext['generation'], name: 'generation'); + if (generation > _maxSafeJsonInteger) { + throw const FormatException( + 'generation exceeds the safe JSON integer range', + ); + } +} + +void validateBuzzPushLeasePlaintext( + Map plaintext, { + int maxEndpointLength = 4096, + int maxStringLength = 512, +}) { + _requireExactKeys( + plaintext, + required: const { + 'v', + 'origin', + 'app_profile', + 'transport', + 'endpoint', + 'generation', + 'active', + 'subscriptions', + }, + allowed: const { + 'v', + 'origin', + 'app_profile', + 'transport', + 'endpoint', + 'generation', + 'active', + 'subscriptions', + }, + name: 'lease plaintext', + ); + if (plaintext['v'] != 1) { + throw const FormatException('lease version must be 1'); + } + final origin = _canonicalOrigin(plaintext['origin']); + _checkStringLength(origin, maxStringLength, name: 'origin'); + if (plaintext['app_profile'] != buzzDevPushAppProfile) { + throw const FormatException('lease app_profile must be buzz-ios-dogfood'); + } + if (plaintext['transport'] != buzzPushTransport) { + throw const FormatException('lease transport must be apns'); + } + _checkStringLength( + buzzDevPushAppProfile, + maxStringLength, + name: 'app_profile', + ); + _checkStringLength(buzzPushTransport, maxStringLength, name: 'transport'); + final endpoint = _nonEmptyString(plaintext['endpoint'], name: 'endpoint'); + _checkStringLength(endpoint, maxEndpointLength, name: 'endpoint'); + final generation = _positiveInt(plaintext['generation'], name: 'generation'); + if (generation > _maxSafeJsonInteger) { + throw const FormatException( + 'generation exceeds the safe JSON integer range', + ); + } + if (plaintext['active'] != true) { + throw const FormatException('push lease must be active'); + } + + final subscriptions = _mapList( + plaintext['subscriptions'], + name: 'subscriptions', + ); + if (subscriptions.isEmpty || + subscriptions.length > buzzPushMaxSubscriptions) { + throw const FormatException('push lease subscription count is invalid'); + } + for (final subscription in subscriptions) { + BuzzPushSubscription.fromJson(subscription); + } +} + +void _validateGrant( + BuzzPushEndpointGrant grant, + BuzzPushLeaseDescriptor descriptor, +) { + if (utf8.encode(grant.installationId).length > 64 || + !RegExp(_installationIdPattern).hasMatch(grant.installationId)) { + throw const FormatException( + 'Installation id must be 16 random bytes encoded as lowercase hex', + ); + } + if (grant.relayPubkey != descriptor.executorPubkey) { + throw const FormatException( + '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( + 'Endpoint grant violates the advertised endpoint limit', + ); + } + if (grant.endpointEpoch <= 0) { + throw const FormatException('Endpoint grant epoch is invalid'); + } + if (grant.generation <= 0 || grant.generation > _maxSafeJsonInteger) { + throw const FormatException('Endpoint grant generation is invalid'); + } +} + +String _canonicalOrigin(Object? value) { + final origin = _nonEmptyString(value, name: 'origin'); + final uri = Uri.tryParse(origin); + if (uri == null || + !const {'ws', 'wss'}.contains(uri.scheme) || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.path.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + '${uri.scheme}://${uri.authority}' != origin) { + throw FormatException('Invalid canonical push origin: $origin'); + } + return origin; +} + +void _checkStringLength(String value, int maximum, {required String name}) { + if (maximum <= 0 || utf8.encode(value).length > maximum) { + throw FormatException('$name exceeds its advertised byte limit'); + } +} + +String _lowercaseHex64(Object? value, {required String name}) { + final text = _nonEmptyString(value, name: name); + if (!RegExp(_lowercaseHex64Pattern).hasMatch(text)) { + throw FormatException('$name must be exactly 64 lowercase hex characters'); + } + return text; +} + +String _nonEmptyString(Object? value, {required String name}) { + if (value is! String || value.isEmpty) { + throw FormatException('$name must be a non-empty string'); + } + return value; +} + +int _positiveInt(Object? value, {required String name}) { + if (value is! int || value <= 0) { + throw FormatException('$name must be a positive integer'); + } + return value; +} + +Map _stringMap(Object? value, {required String name}) { + if (value is! Map) { + throw FormatException('$name must be an object'); + } + return value; +} + +List> _mapList(Object? value, {required String name}) { + if (value is! List || value.isEmpty) { + throw FormatException('$name must be a non-empty array'); + } + return [ + for (final item in value) + if (item is Map) + item + else + throw FormatException('$name entries must be objects'), + ]; +} + +List _stringList(Object? value, {required String name}) { + if (value is! List || value.isEmpty) { + throw FormatException('$name must be a non-empty string array'); + } + return [ + for (final item in value) + if (item is String && item.isNotEmpty) + item + else + throw FormatException('$name entries must be non-empty strings'), + ]; +} + +List _intList( + Object? value, { + required String name, + bool allowEmpty = false, +}) { + if (value is! List || (!allowEmpty && value.isEmpty)) { + throw FormatException( + '$name must be ${allowEmpty ? 'an' : 'a non-empty'} integer array', + ); + } + return [ + for (final item in value) + if (item is int && item >= 0) + item + else + throw FormatException('$name entries must be non-negative integers'), + ]; +} + +void _requireExactKeys( + Map value, { + required Set required, + required Set allowed, + required String name, +}) { + final missing = required.difference(value.keys.toSet()); + if (missing.isNotEmpty) { + throw FormatException('$name is missing ${missing.join(', ')}'); + } + final unexpected = value.keys.toSet().difference(allowed); + if (unexpected.isNotEmpty) { + throw FormatException('$name contains unknown field ${unexpected.first}'); + } +} diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart new file mode 100644 index 00000000000..bf22c551395 --- /dev/null +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -0,0 +1,176 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../community/community.dart'; +import '../community/community_provider.dart'; +import '../relay/relay_provider.dart'; +import '../relay/relay_session.dart'; +import '../relay/signed_event_relay.dart'; +import 'dev_push_lease.dart'; +import 'push_bridge.dart'; +import 'push_subscription.dart'; + +/// Starts the push lifecycle only after authenticated relay connectivity and a +/// push-capable NIP-11 descriptor are both present. +class BuzzPushBootstrap extends HookConsumerWidget { + const BuzzPushBootstrap({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(apnsDeviceToken); + useListenable(pushAuthorizationGranted); + final authorizationAttempt = useRef(null); + final publicationAttempt = useRef(null); + final session = ref.watch(relaySessionProvider); + final config = ref.watch(relayConfigProvider); + final community = ref.watch(activeCommunityProvider).value; + final memberPubkey = ref.watch(myPubkeyProvider); + + useEffect(() { + if (!_ready(session, config, community, memberPubkey)) return null; + final attempt = '${community!.id}|${config.baseUrl}'; + if (authorizationAttempt.value == attempt) return null; + authorizationAttempt.value = attempt; + unawaited( + _authorize(config.baseUrl).catchError((Object error, StackTrace stack) { + authorizationAttempt.value = null; + debugPrint('Push authorization bootstrap failed: $error'); + debugPrintStack(stackTrace: stack); + }), + ); + return null; + }, [session.status, config.baseUrl, community?.id, memberPubkey]); + + final token = apnsDeviceToken.value; + final authorized = pushAuthorizationGranted.value; + useEffect( + () { + if (!_ready(session, config, community, memberPubkey) || + authorized != true || + token == null) { + return null; + } + final state = community!.pushSubscriptionState; + if (state.desired.isEmpty) return null; + final desiredFingerprint = buzzPushSubscriptionsFingerprint( + state.desired, + ); + final acceptedFingerprint = state.accepted == null + ? '-' + : buzzPushSubscriptionsFingerprint(state.accepted!); + final attempt = [ + community.id, + config.baseUrl, + token, + desiredFingerprint, + acceptedFingerprint, + state.acceptedGeneration, + state.acceptedGrantGeneration, + state.acceptedInstallationId, + ].join('|'); + if (publicationAttempt.value == attempt) return null; + publicationAttempt.value = attempt; + final relay = SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: config.nsec!, + ); + unawaited( + _publish(ref, config, community, memberPubkey!, relay).catchError(( + Object error, + StackTrace stack, + ) { + publicationAttempt.value = null; + debugPrint('Push lease bootstrap failed: $error'); + debugPrintStack(stackTrace: stack); + }), + ); + return null; + }, + [ + session.status, + config.baseUrl, + community?.id, + community?.pushSubscriptionState, + memberPubkey, + authorized, + token, + ], + ); + + return child; + } + + static bool _ready( + SessionState session, + RelayConfig config, + Community? community, + String? memberPubkey, + ) => + session.status == SessionStatus.connected && + community != null && + config.nsec != null && + config.nsec!.isNotEmpty && + memberPubkey != null && + memberPubkey.isNotEmpty; + + static Future _authorize(String relayBaseUrl) async { + await fetchBuzzPushLeaseDescriptor(relayBaseUrl); + await requestBuzzPushAuthorization(); + } + + static Future _publish( + WidgetRef ref, + RelayConfig config, + Community community, + String memberPubkey, + SignedEventRelay relay, + ) async { + final state = community.pushSubscriptionState; + final desired = state.desired; + final desiredFingerprint = buzzPushSubscriptionsFingerprint(desired); + final acceptedFingerprint = state.accepted == null + ? null + : buzzPushSubscriptionsFingerprint(state.accepted!); + final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); + final grant = await enrollBuzzPush( + config.wsUrl, + Env.pushGatewayUrl, + communitiesForSnapshotRefresh: + ref.read(communityListProvider).value ?? [community], + ); + if (state.authority == BuzzPushLeaseSubscriptionAuthority.accepted && + acceptedFingerprint == desiredFingerprint && + state.acceptedGrantGeneration == grant.generation && + state.acceptedInstallationId == grant.installationId) { + return; + } + // Relay lease replacement and gateway delegation are independent state + // machines. Subscription changes advance only the kind-30350 generation; + // the opaque grant remains reusable until its own authority changes. + final leaseGeneration = (state.acceptedGeneration ?? 0) + 1; + + await publishBuzzDevPushLeaseThroughRelay( + grant: grant, + leaseGeneration: leaseGeneration, + descriptor: descriptor, + nsec: config.nsec!, + memberPubkey: memberPubkey, + subscriptions: desired, + relay: relay, + ); + await ref + .read(communityListProvider.notifier) + .markPushLeaseAccepted( + community.id, + subscriptions: desired, + generation: leaseGeneration, + grantGeneration: grant.generation, + installationId: grant.installationId, + ); + } +} diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart new file mode 100644 index 00000000000..8341921371c --- /dev/null +++ b/mobile/lib/shared/push/push_bridge.dart @@ -0,0 +1,249 @@ +import 'package:nostr/nostr.dart' as nostr; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../community/community.dart'; +import '../deeplink/deep_link.dart'; +import '../relay/relay_provider.dart'; +import 'push_snapshot.dart'; + +const _channel = MethodChannel('buzz/push'); + +/// Latest APNs registration state, including callbacks replayed by iOS after +/// the Flutter method channel attaches. +final apnsDeviceToken = ValueNotifier(null); +final apnsRegistrationError = ValueNotifier(null); +final pushAuthorizationGranted = ValueNotifier(null); + +final pushEndpointGrants = ValueNotifier>([]); +final pushEndpointGrantError = ValueNotifier(null); + +/// The most recent notification response waiting for app navigation. +/// +/// Native iOS buffers cold-start responses until Dart asks for them. This +/// notifier also carries warm responses into the existing deep-link pipeline. +final pendingPushNotificationLink = ValueNotifier(null); + +MessageDeepLink? _pushNotificationLink(Object? arguments) { + if (arguments is! Map) return null; + final eventId = arguments['eventId']; + final communityId = arguments['communityId']; + final channelId = arguments['channelId']; + if (eventId is! String || + eventId.isEmpty || + communityId is! String || + communityId.isEmpty || + channelId is! String || + channelId.isEmpty) { + return null; + } + return MessageDeepLink( + communityId: communityId, + channelId: channelId, + messageId: eventId, + ); +} + +/// Pulls a notification response that arrived before the Flutter method +/// handler was installed. +Future syncPendingBuzzPushNotificationResponse() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + final arguments = await _channel.invokeMapMethod( + 'takePendingNotificationResponse', + ); + final link = _pushNotificationLink(arguments); + if (link != null) pendingPushNotificationLink.value = link; + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + +Future requestBuzzPushAuthorization() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return false; + try { + final granted = + await _channel.invokeMethod('requestAuthorization') ?? false; + pushAuthorizationGranted.value = granted; + return granted; + } on MissingPluginException { + pushAuthorizationGranted.value = false; + return false; + } +} + +class BuzzPushEndpointGrant { + final String relayOrigin; + final String relayPubkey; + final String installationId; + final String endpointGrant; + final String endpointHash; + final String appProfile; + final int endpointEpoch; + final int generation; + final int expiresAt; + + const BuzzPushEndpointGrant({ + required this.relayOrigin, + required this.relayPubkey, + required this.installationId, + required this.endpointGrant, + required this.endpointHash, + required this.appProfile, + required this.endpointEpoch, + required this.generation, + required this.expiresAt, + }); + + factory BuzzPushEndpointGrant.fromMap(Map map) { + final generation = map['generation'] as int; + return BuzzPushEndpointGrant( + relayOrigin: map['relayOrigin'] as String, + relayPubkey: map['relayPubkey'] as String, + 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, + ); + } +} + +Future> readBuzzPushEndpointGrants() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return const []; + try { + final raw = await _channel.invokeListMethod('endpointGrants'); + final grants = [ + for (final value in raw ?? const []) + BuzzPushEndpointGrant.fromMap(value as Map), + ]; + pushEndpointGrants.value = grants; + pushEndpointGrantError.value = null; + return grants; + } catch (error) { + pushEndpointGrantError.value = error.toString(); + rethrow; + } +} + +/// Enrolls the endpoint and optionally rewrites the NSE snapshot afterward. +/// +/// The rewrite propagates NIP-11 `self` rotations even when the opaque grant +/// and accepted relay lease remain reusable and their generations do not move. +Future enrollBuzzPush( + String relayUrl, + String gatewayUrl, { + List? communitiesForSnapshotRefresh, +}) async { + final raw = await _channel.invokeMapMethod('enrollPush', { + 'relayUrl': relayUrl, + 'gatewayUrl': gatewayUrl, + }); + if (raw == null) { + throw StateError('Native push enrollment returned no grant.'); + } + final grant = BuzzPushEndpointGrant.fromMap(raw); + await readBuzzPushEndpointGrants(); + if (communitiesForSnapshotRefresh != null) { + try { + await registerBuzzPushCommunitySnapshot(communitiesForSnapshotRefresh); + pushCommunitySnapshotError.value = null; + } catch (error, stackTrace) { + reportPushCommunitySnapshotError(error, stackTrace); + } + } + return grant; +} + +/// Latest failure to export the community snapshot used by the iOS +/// notification service extension. Snapshot export is push enrichment and must +/// never gate authentication or community persistence. +final pushCommunitySnapshotError = ValueNotifier(null); +final pushLeaseCleanupError = ValueNotifier(null); + +void reportPushCommunitySnapshotError(Object error, StackTrace stackTrace) { + pushCommunitySnapshotError.value = error.toString(); + debugPrint('Push community snapshot export failed: $error'); + debugPrintStack(stackTrace: stackTrace); +} + +void reportPushLeaseCleanupError(Object error, StackTrace stackTrace) { + pushLeaseCleanupError.value = error.toString(); + debugPrint( + 'Push lease cleanup failed; relay expiry remains the fallback: $error', + ); + debugPrintStack(stackTrace: stackTrace); +} + +Future registerBuzzPushCommunitySnapshot( + List communities, +) async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + final snapshots = [ + for (final community in communities) + BuzzPushCommunitySnapshot( + id: community.id, + name: community.name, + relayUrl: community.relayUrl, + pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec), + pushSubscriptionState: community.pushSubscriptionState, + ), + ]; + final signingKeys = {}; + for (final community in communities) { + final nsec = community.nsec; + if (nsec == null || nsec.isEmpty) continue; + try { + final decoded = nostr.Nip19.decode(payload: nsec); + if (decoded.prefix != nostr.Nip19Prefix.nsec || + decoded.data.length != 64) { + continue; + } + signingKeys[community.id] = decoded.data; + } catch (_) { + // Native storage is fail-closed; malformed keys are never exported. + } + } + await _channel.invokeMethod('saveCommunitySnapshot', { + 'communities': [for (final snapshot in snapshots) snapshot.toJson()], + 'signingKeys': signingKeys, + }); + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + +void installBuzzPushMethodHandler() { + _channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'apnsTokenChanged': + final args = call.arguments; + if (args is Map) { + final token = args['token']; + if (token is String && token.isNotEmpty) { + apnsDeviceToken.value = token; + apnsRegistrationError.value = null; + } + } + return null; + case 'apnsRegistrationFailed': + final args = call.arguments; + final message = args is Map ? args['message'] : null; + apnsRegistrationError.value = message is String && message.isNotEmpty + ? message + : 'APNs registration failed'; + debugPrint('APNs registration failed: ${apnsRegistrationError.value}'); + return null; + case 'notificationOpened': + final link = _pushNotificationLink(call.arguments); + if (link == null) return 'ignored'; + pendingPushNotificationLink.value = link; + return 'handled'; + default: + throw MissingPluginException('Unknown buzz/push method ${call.method}'); + } + }); +} diff --git a/mobile/lib/shared/push/push_capability.dart b/mobile/lib/shared/push/push_capability.dart new file mode 100644 index 00000000000..55aafb85165 --- /dev/null +++ b/mobile/lib/shared/push/push_capability.dart @@ -0,0 +1,6 @@ +/// Compile-time iOS push capability. It is false for every normal build and is +/// injected as a Dart define only by the tracked PushEnabled.xcconfig overlay. +const buzzPushCapabilityEnabled = bool.fromEnvironment( + 'BUZZ_PUSH_ENABLED', + defaultValue: false, +); diff --git a/mobile/lib/shared/push/push_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart new file mode 100644 index 00000000000..e42d94b8299 --- /dev/null +++ b/mobile/lib/shared/push/push_presentation_cache.dart @@ -0,0 +1,252 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../relay/nostr_models.dart'; +import 'push_capability.dart'; + +const _pushPresentationChannel = MethodChannel('buzz/push'); +// Keep these bridge payload bounds aligned with BuzzPushPresentationCacheStore. +const _maximumPresentationProfiles = 256; +const _maximumPresentationChannels = 512; +const _maximumAvatarSourceBytes = 512 * 1024; +const _maximumAvatarPNGBytes = 64 * 1024; +Future _avatarEncodeTail = Future.value(); + +/// The latest best-effort App Group presentation-cache failure. +final pushPresentationCacheError = ValueNotifier(null); + +/// Revalidates a relay event before it crosses into the native cache writer. +bool isVerifiedPushPresentationEvent(NostrEvent event) { + try { + nostr.Event( + event.id, + event.pubkey, + event.createdAt, + event.kind, + event.tags, + event.content, + event.sig, + ); + return true; + } catch (_) { + return false; + } +} + +/// Exports raw verified kind-0 events. Native code verifies them again before storage. +Future cacheBuzzPushProfileEvents( + String communityID, + Iterable events, +) async { + if (!buzzPushCapabilityEnabled || + defaultTargetPlatform != TargetPlatform.iOS || + communityID.isEmpty) { + return; + } + final verified = _boundedNewestEvents( + events, + kind: 0, + maximum: _maximumPresentationProfiles, + scope: (event) => event.pubkey.toLowerCase(), + ).values.toList(); + if (verified.isEmpty) return; + await _invokeBestEffort('cachePresentationProfiles', { + 'communityId': communityID, + 'events': [for (final event in verified) event.toJson()], + }); +} + +/// Exports verified channel metadata and membership for native authority checks. +Future cacheBuzzPushChannelEvents( + String communityID, + Iterable metadataEvents, + Iterable membershipEvents, +) async { + if (!buzzPushCapabilityEnabled || + defaultTargetPlatform != TargetPlatform.iOS || + communityID.isEmpty) { + return; + } + final batch = selectBoundedPushChannelEvents( + metadataEvents, + membershipEvents, + ); + final verifiedMetadata = batch.metadata; + final verifiedMembership = batch.membership; + if (verifiedMetadata.isEmpty && verifiedMembership.isEmpty) return; + await _invokeBestEffort('cachePresentationChannels', { + 'communityId': communityID, + 'metadataEvents': [for (final event in verifiedMetadata) event.toJson()], + 'membershipEvents': [ + for (final event in verifiedMembership) event.toJson(), + ], + }); +} + +/// Selects a bounded, paired set of verified channel metadata and membership events. +@visibleForTesting +({List metadata, List membership}) +selectBoundedPushChannelEvents( + Iterable metadataEvents, + Iterable membershipEvents, { + @visibleForTesting int maximumChannels = _maximumPresentationChannels, +}) { + final verifiedMembershipByChannel = _boundedNewestEvents( + membershipEvents, + kind: 39002, + maximum: maximumChannels, + scope: (event) => event.getTagValue('d'), + ); + final selectedChannelIDs = verifiedMembershipByChannel.keys.toSet(); + final verifiedMetadataByChannel = _boundedNewestEvents( + metadataEvents, + kind: 39000, + maximum: maximumChannels, + scope: (event) => event.getTagValue('d'), + allowedScopes: selectedChannelIDs.isEmpty ? null : selectedChannelIDs, + ); + if (selectedChannelIDs.isEmpty) { + selectedChannelIDs.addAll(verifiedMetadataByChannel.keys); + } + final verifiedMetadata = [ + for (final entry in verifiedMetadataByChannel.entries) + if (selectedChannelIDs.contains(entry.key)) entry.value, + ]; + final verifiedMembership = [ + for (final entry in verifiedMembershipByChannel.entries) + if (selectedChannelIDs.contains(entry.key)) entry.value, + ]; + return (metadata: verifiedMetadata, membership: verifiedMembership); +} + +Map _boundedNewestEvents( + Iterable events, { + required int kind, + required int maximum, + required String? Function(NostrEvent event) scope, + Set? allowedScopes, +}) { + final selected = {}; + if (maximum <= 0) return selected; + for (final event in events) { + if (event.kind != kind || !isVerifiedPushPresentationEvent(event)) continue; + final key = scope(event); + if (key == null || key.isEmpty) continue; + if (allowedScopes != null && !allowedScopes.contains(key)) continue; + final existing = selected[key]; + if (existing != null) { + if (_isNewerEvent(event, existing)) selected[key] = event; + continue; + } + if (selected.length < maximum) { + selected[key] = event; + continue; + } + final oldest = selected.entries.reduce( + (left, right) => _isNewerEvent(left.value, right.value) ? right : left, + ); + if (_isNewerEvent(event, oldest.value)) { + selected + ..remove(oldest.key) + ..[key] = event; + } + } + return selected; +} + +bool _isNewerEvent(NostrEvent candidate, NostrEvent existing) => + candidate.createdAt > existing.createdAt || + (candidate.createdAt == existing.createdAt && + candidate.id.compareTo(existing.id) < 0); + +/// Reuses bytes already fetched for a visible foreground avatar. +/// +/// This never starts network I/O. Oversized, malformed, or unsupported images +/// are ignored, and notification delivery remains independent of the cache. +Future cacheBuzzPushAvatarFromLoadedBytes( + String communityID, + String sourceURL, + Uint8List sourceBytes, +) async { + if (!buzzPushCapabilityEnabled || + defaultTargetPlatform != TargetPlatform.iOS || + communityID.isEmpty || + sourceBytes.isEmpty || + sourceBytes.length > _maximumAvatarSourceBytes || + !_isRemoteImageURL(sourceURL)) { + return; + } + final previous = _avatarEncodeTail; + final release = Completer(); + _avatarEncodeTail = release.future; + await previous; + try { + final png = await _boundedAvatarPNG(sourceBytes); + if (png == null) return; + await _invokeBestEffort('cachePresentationAvatar', { + 'communityId': communityID, + 'sourceUrl': sourceURL, + 'png': png, + }); + } finally { + release.complete(); + } +} + +Future _invokeBestEffort( + String method, + Map arguments, +) async { + try { + await _pushPresentationChannel.invokeMethod(method, arguments); + pushPresentationCacheError.value = null; + } on MissingPluginException { + // Push-free builds and non-Runner embeddings intentionally omit the bridge. + } catch (error, stackTrace) { + pushPresentationCacheError.value = error.toString(); + debugPrint('Push presentation cache update failed: $error'); + debugPrintStack(stackTrace: stackTrace); + } +} + +bool _isRemoteImageURL(String value) { + final uri = Uri.tryParse(value.trim()); + return uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty && + uri.userInfo.isEmpty; +} + +Future _boundedAvatarPNG(Uint8List sourceBytes) async { + for (final size in const [128, 96, 64, 48]) { + ui.Codec? codec; + ui.Image? image; + try { + codec = await ui.instantiateImageCodec( + sourceBytes, + targetWidth: size, + targetHeight: size, + allowUpscaling: false, + ); + final frame = await codec.getNextFrame(); + image = frame.image; + final data = await image.toByteData(format: ui.ImageByteFormat.png); + if (data == null) continue; + final png = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + if (png.isNotEmpty && png.length <= _maximumAvatarPNGBytes) return png; + } catch (_) { + return null; + } finally { + image?.dispose(); + codec?.dispose(); + } + } + return null; +} diff --git a/mobile/lib/shared/push/push_snapshot.dart b/mobile/lib/shared/push/push_snapshot.dart new file mode 100644 index 00000000000..2760d26ae86 --- /dev/null +++ b/mobile/lib/shared/push/push_snapshot.dart @@ -0,0 +1,38 @@ +import 'push_subscription.dart'; + +/// The minimum community state shared with the iOS notification extension. +class BuzzPushCommunitySnapshot { + final String id; + final String name; + final String relayUrl; + final String? pubkey; + final BuzzPushLeaseSubscriptionState pushSubscriptionState; + + const BuzzPushCommunitySnapshot({ + required this.id, + required this.name, + required this.relayUrl, + this.pubkey, + required this.pushSubscriptionState, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'relayUrl': relayUrl, + if (pubkey != null) 'pubkey': pubkey, + 'pushSubscriptionState': pushSubscriptionState.toJson(), + }; + + factory BuzzPushCommunitySnapshot.fromJson(Map json) { + return BuzzPushCommunitySnapshot( + id: json['id'] as String, + name: json['name'] as String, + relayUrl: json['relayUrl'] as String, + pubkey: json['pubkey'] as String?, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.fromJson( + Map.from(json['pushSubscriptionState'] as Map), + ), + ); + } +} diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart new file mode 100644 index 00000000000..acd2e96e0ae --- /dev/null +++ b/mobile/lib/shared/push/push_subscription.dart @@ -0,0 +1,479 @@ +import 'dart:convert'; + +/// User-visible Buzz message kinds. This mirrors +/// `EventKind.channelMessageEventKinds` without importing feature code into +/// the shared push layer. +const buzzPushEligibleKinds = [9, 40002, 45001, 45003]; +const buzzPushSelfDirectedKinds = buzzPushEligibleKinds; +const buzzPushRenderableKinds = buzzPushEligibleKinds; +const buzzPushChannelKinds = [9]; +const buzzPushChannelChunkSize = 50; +const buzzPushMaxSubscriptions = 16; +const buzzPushMaxIgnoreFilters = 8; +const buzzPushHellthreadParticipantLimit = 20; + +const _supportedNotificationClasses = {'silent', 'default', 'time_sensitive'}; +const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'}; +final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$'); +final _channelIdPattern = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', +); + +enum BuzzPushLeaseSubscriptionAuthority { desired, accepted } + +class BuzzPushFilter { + final List kinds; + final List? authors; + final List? pTags; + final List? hTags; + final List? eTags; + + BuzzPushFilter({ + required Iterable kinds, + Iterable? authors, + Iterable? pTags, + Iterable? hTags, + Iterable? eTags, + }) : kinds = List.unmodifiable(kinds), + authors = _optionalList(authors), + pTags = _optionalList(pTags), + hTags = _optionalList(hTags), + eTags = _optionalList(eTags) { + _validate(); + } + + Map toJson() => { + 'kinds': kinds, + if (authors != null) 'authors': authors, + if (pTags != null) '#p': pTags, + if (hTags != null) '#h': hTags, + if (eTags != null) '#e': eTags, + }; + + factory BuzzPushFilter.fromJson(Map json) { + _rejectUnknownKeys(json, _filterKeys, 'push filter'); + return BuzzPushFilter( + kinds: _intList(json, 'kinds'), + authors: _optionalStringList(json, 'authors'), + pTags: _optionalStringList(json, '#p'), + hTags: _optionalStringList(json, '#h'), + eTags: _optionalStringList(json, '#e'), + ); + } + + void _validate() { + if (kinds.isEmpty || + kinds.any((kind) => !buzzPushEligibleKinds.contains(kind))) { + throw const FormatException('Push filter contains invalid kinds.'); + } + for (final value in [...?authors, ...?pTags, ...?eTags]) { + if (!_exactHexPattern.hasMatch(value)) { + throw const FormatException( + 'Push filter contains a non-exact hex value.', + ); + } + } + for (final value in hTags ?? const []) { + if (!_channelIdPattern.hasMatch(value)) { + throw const FormatException( + 'Push filter contains an invalid channel ID.', + ); + } + } + } +} + +class BuzzPushSuppression { + final int pTagsMax; + + const BuzzPushSuppression({required this.pTagsMax}) : assert(pTagsMax > 0); + + Map toJson() => {'p_tags_max': pTagsMax}; + + factory BuzzPushSuppression.fromJson(Map json) { + _rejectUnknownKeys(json, const {'p_tags_max'}, 'push suppression'); + final value = json['p_tags_max']; + if (value is! int || value <= 0) { + throw const FormatException('p_tags_max must be a positive integer.'); + } + return BuzzPushSuppression(pTagsMax: value); + } +} + +class BuzzPushSubscription { + final BuzzPushFilter filter; + final String notificationClass; + final List ignore; + final BuzzPushSuppression? suppress; + + BuzzPushSubscription({ + required this.filter, + required this.notificationClass, + Iterable ignore = const [], + this.suppress, + }) : ignore = List.unmodifiable(ignore) { + if (!_supportedNotificationClasses.contains(notificationClass)) { + throw const FormatException('Unsupported push notification class.'); + } + if (filter.authors == null && + filter.pTags == null && + filter.hTags == null) { + throw const FormatException('Push subscription filter is not narrowed.'); + } + if (this.ignore.length > buzzPushMaxIgnoreFilters) { + throw const FormatException( + 'Push subscription has too many ignore filters.', + ); + } + } + + Map toJson() => { + 'filter': filter.toJson(), + 'class': notificationClass, + if (ignore.isNotEmpty) + 'ignore': [for (final filter in ignore) filter.toJson()], + if (suppress != null) 'suppress': suppress!.toJson(), + }; + + factory BuzzPushSubscription.fromJson(Map json) { + _rejectUnknownKeys(json, const { + 'filter', + 'class', + 'ignore', + 'suppress', + }, 'push subscription'); + final filter = json['filter']; + final notificationClass = json['class']; + final ignore = json['ignore']; + final suppress = json['suppress']; + if (filter is! Map || notificationClass is! String) { + throw const FormatException('Malformed push subscription.'); + } + if (ignore != null && ignore is! List) { + throw const FormatException('Push subscription ignore must be a list.'); + } + if (suppress != null && suppress is! Map) { + throw const FormatException( + 'Push subscription suppress must be an object.', + ); + } + return BuzzPushSubscription( + filter: BuzzPushFilter.fromJson(Map.from(filter)), + notificationClass: notificationClass, + ignore: [ + for (final raw in ignore as List? ?? const []) + if (raw is Map) + BuzzPushFilter.fromJson(Map.from(raw)) + else + throw const FormatException('Malformed push ignore filter.'), + ], + suppress: suppress == null + ? null + : BuzzPushSuppression.fromJson(Map.from(suppress)), + ); + } +} + +/// Desired and relay-accepted lease subscriptions are intentionally separate. +/// Keeping both sets makes relay rejection or expiry detectable instead of +/// assuming the desired lease was accepted unchanged. +class BuzzPushLeaseSubscriptionState { + final BuzzPushLeaseSubscriptionAuthority authority; + final List desired; + final List? accepted; + + /// Monotonic generation of the relay-facing kind-30350 lease. + final int? acceptedGeneration; + + /// Generation sealed into the gateway's opaque relay delegation grant. + final int? acceptedGrantGeneration; + final String? acceptedInstallationId; + + const BuzzPushLeaseSubscriptionState.desired({ + this.desired = const [], + this.accepted, + this.acceptedGeneration, + this.acceptedGrantGeneration, + this.acceptedInstallationId, + }) : authority = BuzzPushLeaseSubscriptionAuthority.desired; + + BuzzPushLeaseSubscriptionState.accepted({ + required Iterable desired, + required Iterable acceptedSubscriptions, + required this.acceptedGeneration, + required this.acceptedGrantGeneration, + required this.acceptedInstallationId, + }) : authority = BuzzPushLeaseSubscriptionAuthority.accepted, + desired = List.unmodifiable(desired), + accepted = List.unmodifiable(acceptedSubscriptions) { + if (acceptedGeneration == null || acceptedGeneration! <= 0) { + throw const FormatException( + 'Accepted push authority requires a positive lease generation.', + ); + } + if (acceptedGrantGeneration == null || acceptedGrantGeneration! <= 0) { + throw const FormatException( + 'Accepted push authority requires a positive gateway grant generation.', + ); + } + if (acceptedInstallationId == null || acceptedInstallationId!.isEmpty) { + throw const FormatException( + 'Accepted push authority requires an installation ID.', + ); + } + } + + List get authoritative => switch (authority) { + BuzzPushLeaseSubscriptionAuthority.desired => desired, + BuzzPushLeaseSubscriptionAuthority.accepted => accepted!, + }; + + BuzzPushLeaseSubscriptionState withDesired( + Iterable subscriptions, + ) { + final updated = List.unmodifiable(subscriptions); + return switch (authority) { + BuzzPushLeaseSubscriptionAuthority.desired => + BuzzPushLeaseSubscriptionState.desired( + desired: updated, + accepted: accepted, + acceptedGeneration: acceptedGeneration, + acceptedGrantGeneration: acceptedGrantGeneration, + acceptedInstallationId: acceptedInstallationId, + ), + BuzzPushLeaseSubscriptionAuthority.accepted => + BuzzPushLeaseSubscriptionState.accepted( + desired: updated, + acceptedSubscriptions: accepted!, + acceptedGeneration: acceptedGeneration, + acceptedGrantGeneration: acceptedGrantGeneration, + acceptedInstallationId: acceptedInstallationId, + ), + }; + } + + BuzzPushLeaseSubscriptionState withAccepted({ + required Iterable subscriptions, + required int generation, + required int grantGeneration, + required String installationId, + }) => BuzzPushLeaseSubscriptionState.accepted( + desired: desired, + acceptedSubscriptions: subscriptions, + acceptedGeneration: generation, + acceptedGrantGeneration: grantGeneration, + acceptedInstallationId: installationId, + ); + + Map toJson() => { + 'authority': authority.name, + 'desired': [for (final subscription in desired) subscription.toJson()], + if (accepted != null) + 'accepted': [for (final subscription in accepted!) subscription.toJson()], + if (acceptedGeneration != null) 'acceptedGeneration': acceptedGeneration, + if (acceptedGrantGeneration != null) + 'acceptedGrantGeneration': acceptedGrantGeneration, + if (acceptedInstallationId != null) + 'acceptedInstallationId': acceptedInstallationId, + }; + + factory BuzzPushLeaseSubscriptionState.fromJson(Map json) { + _rejectUnknownKeys(json, const { + 'authority', + 'desired', + 'accepted', + 'acceptedGeneration', + 'acceptedGrantGeneration', + 'acceptedInstallationId', + }, 'push subscription state'); + final authority = json['authority']; + final desired = _subscriptionList( + json['desired'], + 'desired', + allowEmpty: authority == 'desired', + ); + final acceptedRaw = json['accepted']; + final accepted = acceptedRaw == null + ? null + : _subscriptionList(acceptedRaw, 'accepted'); + final acceptedGeneration = json['acceptedGeneration']; + // Pre-MVP snapshots coupled relay and gateway generations; interpreting + // that single value as both preserves them until the next publication. + final acceptedGrantGeneration = + json['acceptedGrantGeneration'] ?? acceptedGeneration; + final acceptedInstallationId = json['acceptedInstallationId']; + if (acceptedGeneration != null && acceptedGeneration is! int) { + throw const FormatException( + 'Accepted push lease generation must be an integer.', + ); + } + if (acceptedGrantGeneration != null && acceptedGrantGeneration is! int) { + throw const FormatException( + 'Accepted gateway grant generation must be an integer.', + ); + } + if (acceptedInstallationId != null && acceptedInstallationId is! String) { + throw const FormatException( + 'Accepted push installation ID must be a string.', + ); + } + return switch (authority) { + 'desired' => BuzzPushLeaseSubscriptionState.desired( + desired: desired, + accepted: accepted, + acceptedGeneration: acceptedGeneration as int?, + acceptedGrantGeneration: acceptedGrantGeneration as int?, + acceptedInstallationId: acceptedInstallationId as String?, + ), + 'accepted' + when accepted != null && + acceptedGeneration is int && + acceptedGrantGeneration is int && + acceptedInstallationId is String => + BuzzPushLeaseSubscriptionState.accepted( + desired: desired, + acceptedSubscriptions: accepted, + acceptedGeneration: acceptedGeneration, + acceptedGrantGeneration: acceptedGrantGeneration, + acceptedInstallationId: acceptedInstallationId, + ), + 'accepted' => throw const FormatException( + 'Accepted push authority requires accepted subscriptions and generations.', + ), + _ => throw const FormatException('Unknown push subscription authority.'), + }; + } +} + +List buildDesiredBuzzPushSubscriptions({ + required String myPubkey, + Iterable channelIds = const [], + Iterable mutedChannelIds = const [], +}) { + final normalizedPubkey = myPubkey.toLowerCase(); + if (!_exactHexPattern.hasMatch(normalizedPubkey)) { + throw const FormatException('Push subscription pubkey must be exact hex.'); + } + + final normalizedChannelIds = channelIds.map(_normalizeChannelID).toSet(); + final normalizedMuted = mutedChannelIds.map(_normalizeChannelID).toSet(); + final activeMuted = + normalizedMuted.intersection(normalizedChannelIds).toList()..sort(); + final mutedIgnoreFilters = []; + for (final chunk in _chunks(activeMuted, buzzPushChannelChunkSize)) { + mutedIgnoreFilters.add( + BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk), + ); + } + if (mutedIgnoreFilters.length + 1 > buzzPushMaxIgnoreFilters) { + throw const FormatException('Too many muted channels for a push lease.'); + } + + final selfAuthored = BuzzPushFilter( + kinds: buzzPushRenderableKinds, + authors: [normalizedPubkey], + ); + final ignores = [selfAuthored, ...mutedIgnoreFilters]; + const suppression = BuzzPushSuppression( + pTagsMax: buzzPushHellthreadParticipantLimit, + ); + final subscriptions = [ + BuzzPushSubscription( + filter: BuzzPushFilter( + kinds: buzzPushSelfDirectedKinds, + pTags: [normalizedPubkey], + ), + notificationClass: 'default', + ignore: ignores, + suppress: suppression, + ), + ]; + + final channels = normalizedChannelIds.difference(normalizedMuted).toList() + ..sort(); + for (final chunk in _chunks(channels, buzzPushChannelChunkSize)) { + subscriptions.add( + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk), + notificationClass: 'default', + ignore: ignores, + suppress: suppression, + ), + ); + } + if (subscriptions.length > buzzPushMaxSubscriptions) { + throw const FormatException('Too many channels for a push lease.'); + } + return List.unmodifiable(subscriptions); +} + +String buzzPushSubscriptionsFingerprint( + List subscriptions, +) => jsonEncode([ + for (final subscription in subscriptions) subscription.toJson(), +]); + +String buzzPushSubscriptionStateFingerprint( + BuzzPushLeaseSubscriptionState state, +) => jsonEncode(state.toJson()); + +List> _chunks(List values, int size) => [ + for (var offset = 0; offset < values.length; offset += size) + values.sublist(offset, (offset + size).clamp(0, values.length)), +]; + +String _normalizeChannelID(String value) { + final normalized = value.toLowerCase(); + if (!_channelIdPattern.hasMatch(normalized)) { + throw const FormatException('Push subscription channel ID is invalid.'); + } + return normalized; +} + +List? _optionalList(Iterable? values) => + values == null ? null : List.unmodifiable(values); + +List _intList(Map json, String key) { + final raw = json[key]; + if (raw is! List || raw.any((value) => value is! int)) { + throw FormatException('$key must be an integer list.'); + } + return raw.cast(); +} + +List? _optionalStringList(Map json, String key) { + if (!json.containsKey(key)) return null; + final raw = json[key]; + if (raw is! List || raw.isEmpty || raw.any((value) => value is! String)) { + throw FormatException('$key must be a non-empty string list.'); + } + return raw.cast(); +} + +List _subscriptionList( + Object? raw, + String label, { + bool allowEmpty = false, +}) { + if (raw is! List || (!allowEmpty && raw.isEmpty)) { + throw FormatException('$label subscriptions must be a non-empty list.'); + } + return [ + for (final item in raw) + if (item is Map) + BuzzPushSubscription.fromJson(Map.from(item)) + else + throw FormatException('Malformed $label subscription.'), + ]; +} + +void _rejectUnknownKeys( + Map json, + Set allowed, + String label, +) { + final unknown = json.keys.where((key) => !allowed.contains(key)); + if (unknown.isNotEmpty) { + throw FormatException('$label contains unknown field ${unknown.first}.'); + } +} diff --git a/mobile/lib/shared/push/push_subscription_provider.dart b/mobile/lib/shared/push/push_subscription_provider.dart new file mode 100644 index 00000000000..653de293527 --- /dev/null +++ b/mobile/lib/shared/push/push_subscription_provider.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../features/channels/channel.dart'; +import '../../features/channels/channel_mutes/channel_mutes_provider.dart'; +import '../../features/channels/channels_provider.dart'; +import '../community/community.dart'; +import '../community/community_provider.dart'; +import '../relay/relay_provider.dart'; +import 'push_subscription.dart'; + +/// Keeps the persisted desired lease and the App Group snapshot aligned with +/// the active identity, joined channels, and mute state. This is desired client +/// policy only. Relay-accepted authority is introduced by lease publication. +final pushSubscriptionSyncProvider = Provider((ref) { + final active = ref.watch(activeCommunityProvider).value; + final channels = ref.watch(channelsProvider).value; + final mutes = ref.watch(channelMutesProvider); + if (active == null || channels == null || !mutes.isReady) return; + + final subscriptions = desiredBuzzPushSubscriptions( + community: active, + channels: channels, + mutedChannelIds: [ + for (final entry in mutes.store.channels.entries) + if (entry.value.muted) entry.key, + ], + ); + if (subscriptions == null) return; + unawaited( + ref + .read(communityListProvider.notifier) + .updateDesiredPushSubscriptions(active.id, subscriptions), + ); +}); + +List? desiredBuzzPushSubscriptions({ + required Community community, + required Iterable channels, + required Iterable mutedChannelIds, +}) { + final pubkey = community.pubkey ?? pubkeyFromNsec(community.nsec); + if (pubkey == null || pubkey.isEmpty) return null; + return buildDesiredBuzzPushSubscriptions( + myPubkey: pubkey, + channelIds: [ + // Activity surfaces channel-wide traffic only for joined DM channels. + // Other message kinds enter the inbox through an exact #p mention or + // participant-thread tag, so subscribing to every joined channel would + // over-notify compared with the product predicate. + for (final channel in channels) + if (channel.isDm && channel.isMember && !channel.isArchived) channel.id, + ], + mutedChannelIds: mutedChannelIds, + ); +} diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index 14187df081c..0ab5b5aa204 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -38,6 +38,10 @@ class MediaImageProvider extends ImageProvider { final double scale; final MediaGetAuthService auth; + /// Optional observer for bytes already fetched by the foreground image path. + /// Excluded from equality because it is a side effect, not cache identity. + final ValueChanged? onBytesLoaded; + /// Excluded from equality: transport, not identity. final http.Client client; @@ -45,6 +49,7 @@ class MediaImageProvider extends ImageProvider { required this.url, required this.auth, required this.client, + this.onBytesLoaded, this.scale = 1.0, }); @@ -109,6 +114,7 @@ class MediaImageProvider extends ImageProvider { _cooldownUntil[url] = debugNow().add(_defaultCooldown); throw NetworkImageLoadException(statusCode: 200, uri: uri); } + onBytesLoaded?.call(bytes); final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); return decode(buffer); } catch (_) { @@ -181,6 +187,7 @@ class MediaImage extends ConsumerWidget { final FilterQuality filterQuality; final double? decodeWidth; final bool boundDecodeToLayout; + final ValueChanged? onBytesLoaded; const MediaImage({ super.key, @@ -194,6 +201,7 @@ class MediaImage extends ConsumerWidget { this.filterQuality = FilterQuality.medium, this.decodeWidth, this.boundDecodeToLayout = true, + this.onBytesLoaded, }); @override @@ -202,6 +210,7 @@ class MediaImage extends ConsumerWidget { url: url, auth: ref.watch(mediaGetAuthServiceProvider), client: ref.watch(mediaHttpClientProvider), + onBytesLoaded: onBytesLoaded, ); if (decodeWidth != null) { diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 061dd6cb386..00fcf65b716 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -62,7 +62,8 @@ class RelayConfig { /// Compile-time environment config via --dart-define. /// /// Run with: -/// flutter run --dart-define=BUZZ_RELAY_URL=http://localhost:3000 +/// flutter run --dart-define=BUZZ_RELAY_URL=http://localhost:3000 \ +/// --dart-define=BUZZ_PUSH_GATEWAY_URL=http://localhost:8080 /// /// Or create a `.env.json` and use --dart-define-from-file=.env.json class Env { @@ -70,6 +71,10 @@ class Env { 'BUZZ_RELAY_URL', defaultValue: 'http://localhost:3000', ); + static const pushGatewayUrl = String.fromEnvironment( + 'BUZZ_PUSH_GATEWAY_URL', + defaultValue: 'https://push.buzz.xyz', + ); } class RelayConfigNotifier extends Notifier { diff --git a/mobile/lib/shared/relay/signed_event_relay.dart b/mobile/lib/shared/relay/signed_event_relay.dart index a739b765941..b0106639eb7 100644 --- a/mobile/lib/shared/relay/signed_event_relay.dart +++ b/mobile/lib/shared/relay/signed_event_relay.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:nostr/nostr.dart' as nostr; import 'nostr_models.dart'; import 'relay_session.dart'; +import 'relay_socket.dart'; /// Signs and submits Nostr events through the relay WebSocket connection. class SignedEventRelay { @@ -57,3 +60,73 @@ class SignedEventRelay { return _session.publish(nostrEvent); } } + +/// Publishes one signed event over a short-lived authenticated NIP-42 socket. +/// +/// This is used for community-removal tombstones because the community being +/// removed is not necessarily the app's active relay session. +Future submitSignedEventOnce({ + required String wsUrl, + required String nsec, + required int kind, + required String content, + required List> tags, + int? createdAt, + Duration timeout = const Duration(seconds: 12), +}) async { + final privateKey = nostr.Nip19.decode(payload: nsec).data; + if (privateKey.isEmpty) throw const FormatException('Invalid nsec'); + final signed = nostr.Event.from( + kind: kind, + content: content, + tags: tags, + secretKey: privateKey, + createdAt: createdAt, + verify: false, + ); + final event = NostrEvent.fromJson(signed.toMap()); + final result = Completer(); + late final RelaySocket socket; + socket = RelaySocket( + wsUrl: wsUrl, + nsec: nsec, + onMessage: (message) { + if (message case [ + 'OK', + final String eventId, + final bool accepted, + final String detail, + ..., + ] when eventId == event.id) { + if (accepted) { + result.complete( + NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: detail, + sig: event.sig, + ), + ); + } else { + result.completeError(Exception('Relay rejected event: $detail')); + } + } + }, + onConnected: () => socket.send(['EVENT', event.toJson()]), + onDisconnected: (error) { + if (!result.isCompleted) { + result.completeError(error ?? Exception('Relay disconnected')); + } + }, + ); + final resultFuture = result.future.timeout(timeout); + try { + await socket.connect(); + return await resultFuture; + } finally { + await socket.disconnect(); + } +} diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 7b40739c4c7..70e34d06d6c 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -1,10 +1,14 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../animated_avatar.dart'; +import '../community/community_provider.dart'; +import '../push/push_presentation_cache.dart'; import '../relay/relay.dart'; /// A circular avatar that supports both remote URLs and inline image data. @@ -50,7 +54,7 @@ class AvatarImage extends StatelessWidget { } /// Image content for avatar surfaces whose shape is supplied by their parent. -class AvatarImageContent extends StatefulWidget { +class AvatarImageContent extends ConsumerStatefulWidget { final String? imageUrl; final Widget fallback; final BoxFit fit; @@ -63,10 +67,10 @@ class AvatarImageContent extends StatefulWidget { }); @override - State createState() => _AvatarImageContentState(); + ConsumerState createState() => _AvatarImageContentState(); } -class _AvatarImageContentState extends State { +class _AvatarImageContentState extends ConsumerState { late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl); @override @@ -80,6 +84,7 @@ class _AvatarImageContentState extends State { @override Widget build(BuildContext context) { final centeredFallback = Center(child: widget.fallback); + final communityID = ref.watch(activeCommunityProvider).value?.id; return switch (_source) { _EmojiAvatarSource(:final emoji, :final color) => ColoredBox( @@ -111,6 +116,11 @@ class _AvatarImageContentState extends State { _NetworkAvatarSource(:final url) => MediaImage( url: url, fit: widget.fit, + onBytesLoaded: communityID == null + ? null + : (bytes) => unawaited( + cacheBuzzPushAvatarFromLoadedBytes(communityID, url, bytes), + ), errorBuilder: (_, _, _) => centeredFallback, ), null => centeredFallback, diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index ea6bc226c21..649d005d81b 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -157,6 +157,54 @@ void main() { expect(destination.link, same(link)); }); + testWidgets('switches to the notification community before dispatch', ( + tester, + ) async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + await storage.save(_firstCommunity); + await storage.save(_notificationCommunity); + await storage.saveActiveId(_firstCommunity.id); + const link = MessageDeepLink( + communityId: 'community-2', + channelId: 'channel-1', + messageId: 'message-2', + ); + + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async {}), + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier(link), + ), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier(Future.value([_channel])), + ), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: DeepLinkDispatcher( + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(await storage.loadActiveId(), _notificationCommunity.id); + final destination = tester.widget<_CapturedDestination>( + find.byType(_CapturedDestination), + ); + expect(destination.link, link); + }); + testWidgets('retains invite and surfaces prepare failure', (tester) async { const link = InviteDeepLink( relayUrl: 'wss://relay.example.com', @@ -404,6 +452,20 @@ final _channel = Channel( isMember: true, ); +final _firstCommunity = Community( + id: 'community-1', + name: 'First', + relayUrl: 'wss://first.example', + addedAt: DateTime(2026), +); + +final _notificationCommunity = Community( + id: 'community-2', + name: 'Notification', + relayUrl: 'wss://notification.example', + addedAt: DateTime(2026), +); + class _CountingCommunityStorage extends CommunityStorage { int loadCalls = 0; diff --git a/mobile/test/shared/auth/auth_provider_test.dart b/mobile/test/shared/auth/auth_provider_test.dart index 7a1e6a66083..b078c6ecddc 100644 --- a/mobile/test/shared/auth/auth_provider_test.dart +++ b/mobile/test/shared/auth/auth_provider_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -5,6 +6,7 @@ import 'package:buzz/shared/auth/auth_provider.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; import '../community/community_storage_test.dart'; @@ -20,8 +22,16 @@ void main() { ); await storage.save(invalid); await storage.saveActiveId(invalid.id); + final snapshots = >[]; final container = ProviderContainer( - overrides: [communityStorageProvider.overrideWithValue(storage)], + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue(( + communities, + ) async { + snapshots.add(List.of(communities)); + }), + ], ); addTearDown(container.dispose); @@ -30,9 +40,159 @@ void main() { expect(auth.status, AuthStatus.unauthenticated); expect(await storage.loadAll(), isEmpty); expect(await storage.loadActiveId(), isNull); + expect(snapshots.last, isEmpty); }, ); + test('authenticate exports the complete stored community snapshot', () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final existing = Community.create( + name: 'Existing', + relayUrl: 'https://existing.example', + nsec: nostr.Keys.generate().nsec, + ); + final added = Community.create( + name: 'Added', + relayUrl: 'https://added.example', + nsec: nostr.Keys.generate().nsec, + ); + await storage.save(existing); + final snapshots = >[]; + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((communities) async { + snapshots.add(List.of(communities)); + }), + ], + ); + addTearDown(container.dispose); + + await container + .read(authProvider.notifier) + .authenticateWithCommunity(added); + + expect(snapshots.last.map((community) => community.id), { + existing.id, + added.id, + }); + }); + + test( + 'sign out removes the active community from the shared snapshot', + () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final first = Community.create( + name: 'First', + relayUrl: 'https://first.example', + nsec: nostr.Keys.generate().nsec, + ); + final second = Community.create( + name: 'Second', + relayUrl: 'https://second.example', + nsec: nostr.Keys.generate().nsec, + ); + await storage.save(first); + await storage.save(second); + await storage.saveActiveId(first.id); + final snapshots = >[]; + final deactivatedCommunityIds = []; + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue(( + communities, + ) async { + snapshots.add(List.of(communities)); + }), + communityPushLeaseDeactivatorProvider.overrideWithValue(( + community, + ) async { + deactivatedCommunityIds.add(community.id); + }), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + + await container.read(authProvider.notifier).signOut(); + + expect( + snapshots.any((snapshot) { + return snapshot.length == 1 && snapshot.single.id == second.id; + }), + isTrue, + ); + expect( + snapshots.last.map((community) => community.id), + isNot(contains(first.id)), + ); + expect(deactivatedCommunityIds, [first.id]); + }, + ); + + test( + 'snapshot export failure does not gate startup authentication', + () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final community = Community.create( + name: 'Existing', + relayUrl: 'https://existing.example', + nsec: nostr.Keys.generate().nsec, + ); + await storage.save(community); + await storage.saveActiveId(community.id); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async { + throw PlatformException( + code: 'save_failed', + message: 'Keychain unavailable', + ); + }), + ], + ); + addTearDown(container.dispose); + + final auth = await container.read(authProvider.future); + + expect(auth.status, AuthStatus.authenticated); + expect(auth.community?.id, community.id); + expect(pushCommunitySnapshotError.value, contains('save_failed')); + }, + ); + + test('snapshot export failure does not gate direct authentication', () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final community = Community.create( + name: 'Added', + relayUrl: 'https://added.example', + nsec: nostr.Keys.generate().nsec, + ); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async { + throw PlatformException( + code: 'save_failed', + message: 'Keychain unavailable', + ); + }), + ], + ); + addTearDown(container.dispose); + + await container + .read(authProvider.notifier) + .authenticateWithCommunity(community); + + final auth = await container.read(authProvider.future); + expect(auth.status, AuthStatus.authenticated); + expect(auth.community?.id, community.id); + expect((await storage.loadAll()).single.id, community.id); + }); + test('falls through to the next valid saved community', () async { final storage = CommunityStorage(secure: FakeSecureStorage()); final invalid = Community.create( diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index c58e03c56ae..2f3a13f2a00 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -1,8 +1,11 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:nostr/nostr.dart' as nostr; import 'community_storage_test.dart'; @@ -10,17 +13,31 @@ void main() { late FakeSecureStorage fakeSecure; late CommunityStorage communityStorage; late ProviderContainer container; + late List> snapshots; + late List deactivatedCommunityIds; setUp(() { fakeSecure = FakeSecureStorage(); communityStorage = CommunityStorage(secure: fakeSecure); + snapshots = []; + deactivatedCommunityIds = []; }); tearDown(() => container.dispose()); ProviderContainer createContainer() { return ProviderContainer( - overrides: [communityStorageProvider.overrideWithValue(communityStorage)], + overrides: [ + communityStorageProvider.overrideWithValue(communityStorage), + communitySnapshotWriterProvider.overrideWithValue((communities) async { + snapshots.add(List.of(communities)); + }), + communityPushLeaseDeactivatorProvider.overrideWithValue(( + community, + ) async { + deactivatedCommunityIds.add(community.id); + }), + ], ); } @@ -29,6 +46,40 @@ void main() { container = createContainer(); final communities = await container.read(communityListProvider.future); expect(communities, isEmpty); + expect(snapshots, [isEmpty]); + }); + + test('exports migrated communities on startup', () async { + final community = Community.create( + name: 'Migrated', + relayUrl: 'https://migrated.example.com', + nsec: nostr.Keys.generate().nsec, + ); + // Seed legacy storage to exercise the same migration path as an app + // upgrade. + fakeSecure['buzz_workspaces'] = jsonEncode([community.toJson()]); + + container = createContainer(); + await container.read(communityListProvider.future); + + expect(snapshots.single.single.id, community.id); + expect(fakeSecure['buzz_workspaces'], isNull); + }); + + test('skips an unchanged snapshot after provider invalidation', () async { + final community = Community.create( + name: 'Stored', + relayUrl: 'https://stored.example.com', + nsec: nostr.Keys.generate().nsec, + ); + await communityStorage.save(community); + container = createContainer(); + + await container.read(communityListProvider.future); + container.invalidate(communityListProvider); + await container.read(communityListProvider.future); + + expect(snapshots, hasLength(1)); }); test('addCommunity adds to list', () async { @@ -61,6 +112,7 @@ void main() { final communities = await container.read(communityListProvider.future); expect(communities, isEmpty); + expect(deactivatedCommunityIds, [ws.id]); }); test('renameCommunity updates name', () async { diff --git a/mobile/test/shared/community/community_storage_test.dart b/mobile/test/shared/community/community_storage_test.dart index be14ef43178..2bb41ca24c8 100644 --- a/mobile/test/shared/community/community_storage_test.dart +++ b/mobile/test/shared/community/community_storage_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; /// In-memory fake that extends Fake to satisfy all FlutterSecureStorage /// interface methods, but implements the core read/write/delete with real @@ -117,6 +118,36 @@ void main() { expect(loaded.first.name, 'Test'); expect(loaded.first.relayUrl, 'https://relay.example.com'); expect(loaded.first.pubkey, 'abc123'); + expect( + loaded.first.pushSubscriptionState.authority, + BuzzPushLeaseSubscriptionAuthority.desired, + ); + }); + + test('round-trips desired push subscription state', () async { + final pubkey = 'a' * 64; + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: pubkey, + channelIds: const ['123e4567-e89b-42d3-a456-426614174000'], + ); + final community = + Community.create( + name: 'Push', + relayUrl: 'https://relay.example.com', + pubkey: pubkey, + ).copyWith( + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ), + ); + + await storage.save(community); + final loaded = (await storage.loadAll()).single; + + expect( + loaded.pushSubscriptionState.toJson(), + community.pushSubscriptionState.toJson(), + ); }); test('save updates existing community with same id', () async { diff --git a/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart b/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart new file mode 100644 index 00000000000..b13f42df6d2 --- /dev/null +++ b/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart @@ -0,0 +1,49 @@ +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + setUp(() { + PendingDeepLinkNotifier.debugUriStreamOverride = const Stream.empty(); + pendingPushNotificationLink.value = null; + }); + + tearDown(() { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + pendingPushNotificationLink.value = null; + }); + + test('parks and consumes a native notification message link', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + expect(container.read(pendingDeepLinkProvider), isNull); + + const link = MessageDeepLink( + communityId: 'community-id', + channelId: 'channel-id', + messageId: 'event-id', + ); + pendingPushNotificationLink.value = link; + await pumpEventQueue(); + + expect(container.read(pendingDeepLinkProvider), link); + container.read(pendingDeepLinkProvider.notifier).consume(); + expect(container.read(pendingDeepLinkProvider), isNull); + expect(pendingPushNotificationLink.value, isNull); + }); + + test('preserves a cold-start target present before provider build', () { + const link = MessageDeepLink( + communityId: 'community-id', + channelId: 'channel-id', + messageId: 'event-id', + ); + pendingPushNotificationLink.value = link; + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(pendingDeepLinkProvider), link); + }); +} diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart new file mode 100644 index 00000000000..6da2dd2116a --- /dev/null +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -0,0 +1,436 @@ +import 'dart:convert'; + +import 'package:buzz/shared/auth/auth_provider.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:buzz/shared/relay/relay_session.dart'; +import 'package:buzz/shared/relay/relay_socket.dart'; +import 'package:buzz/shared/relay/signed_event_relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + final signer = nostr.Keys.generate(); + final relay = nostr.Keys.generate(); + final descriptor = _descriptor(relay.public); + final grant = _grant(relay.public); + final now = DateTime.fromMillisecondsSinceEpoch(1752620000 * 1000); + + test('publishes strict kind-30350 lease and waits for accepted OK', () async { + Map? submitted; + final publication = await publishBuzzDevPushLease( + grant: grant, + leaseGeneration: 7, + descriptor: descriptor, + nsec: signer.nsec, + memberPubkey: signer.public, + subscriptions: [ + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: [signer.public]), + notificationClass: 'default', + ), + ], + now: () => now, + submit: + ({required kind, required content, required tags, createdAt}) async { + submitted = { + 'kind': kind, + 'content': content, + 'tags': tags, + 'createdAt': createdAt, + }; + return const NostrEvent( + id: 'accepted-id', + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: 'saved', + sig: '', + ); + }, + ); + + expect(publication.eventId, 'accepted-id'); + expect(grant.relayOrigin, descriptor.origin); + expect(jsonDecode(publication.plaintext)['origin'], descriptor.origin); + expect(submitted!['kind'], buzzPushLeaseKind); + expect(submitted!['createdAt'], 1752620000); + expect(submitted!['tags'], [ + ['d', 'c' * 32], + ['expiration', '1755212000'], + ['exec', 'relay-v1'], + ]); + final plaintext = nip44Decrypt( + getConversationKey(relay.secret, signer.public), + submitted!['content'] as String, + ); + expect(jsonDecode(plaintext), { + 'v': 1, + 'origin': 'wss://tenant.example:8443', + 'app_profile': 'buzz-ios-dogfood', + 'transport': 'apns', + 'endpoint': 'opaque-grant', + 'generation': 7, + 'active': true, + 'subscriptions': [ + { + 'filter': { + 'kinds': [9], + '#p': [signer.public], + }, + 'class': 'default', + }, + ], + }); + }); + + test( + 'mutation control rejects relay OK false then accepts restored event', + () async { + final events = []; + final acknowledgements = >[]; + final session = RelaySessionNotifier(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + authProvider.overrideWith(() => _UnauthenticatedAuthNotifier()), + ], + ); + await container.read(authProvider.future); + final subscription = container.listen(relaySessionProvider, (_, _) {}); + addTearDown(subscription.close); + addTearDown(container.dispose); + final socket = _MutatingRelaySocket( + events, + onEvent: (event) => event.kind == 40002 + ? (accepted: false, message: 'invalid: kind not push-eligible') + : (accepted: true, message: 'saved'), + onAcknowledgement: acknowledgements.add, + ); + session.debugAttachSocketForTest(socket); + final relayClient = SignedEventRelay(session: session, nsec: signer.nsec); + + final mutated = relayClient.submit( + kind: 40002, + content: 'mutated', + tags: const [], + createdAt: 1752620000, + ); + final rejection = expectLater( + mutated, + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('invalid: kind not push-eligible'), + ), + ), + ); + await _deliverAcknowledgement(acknowledgements, session); + await rejection; + final acceptedFuture = relayClient.submit( + kind: buzzPushLeaseKind, + content: 'restored', + tags: [ + ['d', 'c' * 32], + ['expiration', '1755212000'], + ['exec', 'relay-v1'], + ], + createdAt: 1752620001, + ); + await _deliverAcknowledgement(acknowledgements, session); + final accepted = await acceptedFuture; + + expect(accepted.content, 'saved'); + expect(events.map((event) => event.kind), [40002, buzzPushLeaseKind]); + expect(events.every((event) => event.pubkey == signer.public), isTrue); + for (final event in events) { + expect( + () => nostr.Event( + event.id, + event.pubkey, + event.createdAt, + event.kind, + event.tags, + event.content, + event.sig, + ), + returnsNormally, + ); + } + }, + ); + + test('strict plaintext validator accepts message kinds only', () { + final valid = { + 'v': 1, + 'origin': 'wss://tenant.example:8443', + 'app_profile': 'buzz-ios-dogfood', + 'transport': 'apns', + 'endpoint': 'opaque-grant', + 'generation': 1, + 'active': true, + 'subscriptions': [ + { + 'filter': { + 'kinds': [7], + '#p': [signer.public], + }, + 'class': 'default', + }, + ], + }; + + expect( + () => validateBuzzPushLeasePlaintext(valid), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Push filter contains invalid kinds.', + ), + ), + ); + + ((valid['subscriptions'] as List).single['filter'] as Map)['kinds'] = [ + 9, + 40002, + 45001, + 45003, + ]; + expect(() => validateBuzzPushLeasePlaintext(valid), returnsNormally); + }); + + test('propagates relay rejection instead of accepting locally', () async { + await expectLater( + publishBuzzDevPushLease( + grant: grant, + descriptor: descriptor, + nsec: signer.nsec, + memberPubkey: signer.public, + subscriptions: [ + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: [signer.public]), + notificationClass: 'default', + ), + ], + now: () => now, + submit: + ({ + required kind, + required content, + required tags, + createdAt, + }) async => throw Exception('invalid: origin mismatch'), + ), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('invalid: origin mismatch'), + ), + ), + ); + }); + + test('publishes a minimal higher-generation inactive tombstone', () async { + Map? submitted; + final publication = await publishBuzzPushLeaseTombstone( + descriptor: descriptor, + installationId: grant.installationId, + generation: 3, + nsec: signer.nsec, + memberPubkey: signer.public, + now: () => now, + submit: + ({required kind, required content, required tags, createdAt}) async { + submitted = { + 'kind': kind, + 'content': content, + 'tags': tags, + 'createdAt': createdAt, + }; + return const NostrEvent( + id: 'tombstone-id', + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: 'saved', + sig: '', + ); + }, + ); + + expect(publication.eventId, 'tombstone-id'); + expect(jsonDecode(publication.plaintext), { + 'v': 1, + 'origin': descriptor.origin, + 'generation': 3, + 'active': false, + }); + expect(submitted!['kind'], buzzPushLeaseKind); + expect(submitted!['tags'], [ + ['d', grant.installationId], + ['expiration', '1755212000'], + ['exec', descriptor.executorKeyId], + ]); + final plaintext = nip44Decrypt( + getConversationKey(relay.secret, signer.public), + submitted!['content'] as String, + ); + expect(jsonDecode(plaintext), jsonDecode(publication.plaintext)); + }); + + test('descriptor rejects canonical origin with a trailing slash', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['origin'] = + 'wss://tenant.example:8443/'; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); + + test('descriptor rejects urgent kinds outside push kinds', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['urgent_kinds'] = [7]; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); + + test('descriptor rejects unsupported h grammar', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['h_grammar'] = 'opaque'; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); + + test('descriptor rejects unknown push fields', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['future'] = true; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); +} + +class _UnauthenticatedAuthNotifier extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); +} + +class _MutatingRelaySocket extends RelaySocket { + final List events; + final ({bool accepted, String message}) Function(NostrEvent event) onEvent; + final void Function(List message) _onAcknowledgement; + + _MutatingRelaySocket( + this.events, { + required this.onEvent, + required void Function(List message) onAcknowledgement, + }) : _onAcknowledgement = onAcknowledgement, + super( + wsUrl: 'wss://tenant.example:8443', + nsec: null, + onMessage: _ignoreMessage, + onConnected: _ignoreConnected, + onDisconnected: _ignoreDisconnected, + ); + + @override + SocketState get state => SocketState.connected; + + @override + void send(List payload) { + if (payload case ['EVENT', final Map eventJson]) { + final event = NostrEvent.fromJson(eventJson); + events.add(event); + final response = onEvent(event); + _onAcknowledgement(['OK', event.id, response.accepted, response.message]); + } + } + + @override + Future disconnect() async {} + + @override + void dispose() {} +} + +void _ignoreConnected() {} +void _ignoreDisconnected(Object? _) {} +void _ignoreMessage(List _) {} + +Future _deliverAcknowledgement( + List> acknowledgements, + RelaySessionNotifier session, +) async { + while (acknowledgements.isEmpty) { + await Future.delayed(Duration.zero); + } + session.debugHandleMessage(acknowledgements.removeAt(0)); +} + +BuzzPushLeaseDescriptor _descriptor(String relayPubkey) => + BuzzPushLeaseDescriptor.fromRelayInformation(_descriptorJson(relayPubkey)); + +Map _descriptorJson(String relayPubkey) => { + 'supported_extensions': ['nip-er', 'nip-pl'], + '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], + 'urgent_kinds': [], + 'h_grammar': 'uuid-v4-lowercase', + 'class_support': { + 'apns': ['silent', 'default', 'time_sensitive'], + }, + 'limitation': { + 'max_lease_ttl': 2592000, + 'max_leases_per_pubkey': 16, + 'max_subscriptions_per_lease': 16, + 'max_kinds': 16, + 'max_authors': 20, + 'max_h': 50, + 'max_tag_values': 20, + 'max_ignore': 8, + 'max_content_len': 65536, + 'max_plaintext_len': 32768, + 'max_endpoint_len': 4096, + 'max_string_len': 512, + }, + }, +}; + +BuzzPushEndpointGrant _grant(String relayPubkey) => BuzzPushEndpointGrant( + relayOrigin: 'wss://tenant.example:8443', + relayPubkey: relayPubkey, + 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 new file mode 100644 index 00000000000..448f5b747ae --- /dev/null +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -0,0 +1,315 @@ +import 'dart:async'; + +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _channel = MethodChannel('buzz/push'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + apnsDeviceToken.value = null; + apnsRegistrationError.value = null; + pushAuthorizationGranted.value = null; + pushEndpointGrants.value = const []; + pushEndpointGrantError.value = null; + pushCommunitySnapshotError.value = null; + pendingPushNotificationLink.value = null; + installBuzzPushMethodHandler(); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null); + debugDefaultTargetPlatformOverride = null; + }); + + test('captures APNs token success and clears the previous error', () async { + apnsRegistrationError.value = 'old error'; + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + const MethodCall('apnsTokenChanged', {'token': '01ab'}), + ), + (_) {}, + ); + expect(apnsDeviceToken.value, '01ab'); + expect(apnsRegistrationError.value, isNull); + }); + + test('records denied authorization so enrollment stays blocked', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'requestAuthorization'); + return false; + }); + + expect(await requestBuzzPushAuthorization(), isFalse); + expect(pushAuthorizationGranted.value, isFalse); + }); + + test('reads and exposes persisted endpoint grants on iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'endpointGrants'); + return [_grantMap('opaque-grant')]; + }); + + final grants = await readBuzzPushEndpointGrants(); + + expect(grants, hasLength(1)); + expect(grants.single.relayOrigin, 'wss://relay.example'); + expect(grants.single.relayPubkey, 'a' * 64); + 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); + expect(pushEndpointGrants.value.single.endpointGrant, 'opaque-grant'); + expect(pushEndpointGrantError.value, isNull); + }); + + test( + 'debug enrollment carries the configured relay and gateway URLs', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final methods = []; + final enrollmentArguments = []; + final snapshotArguments = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(_channel, (call) async { + methods.add(call.method); + if (call.method == 'enrollPush') { + enrollmentArguments.add(call.arguments); + return _grantMap('new-grant'); + } + if (call.method == 'endpointGrants') { + return [_grantMap('new-grant')]; + } + if (call.method == 'saveCommunitySnapshot') { + snapshotArguments.add(call.arguments); + return null; + } + fail('Unexpected method ${call.method}'); + }); + + final firstGrant = await enrollBuzzPush( + 'wss://relay.example/', + 'https://gateway-one.example/', + ); + final secondGrant = await enrollBuzzPush( + 'wss://relay.example/', + 'https://gateway-two.example/', + communitiesForSnapshotRefresh: [ + Community( + id: 'community-id', + name: 'Community', + relayUrl: 'wss://relay.example/', + pubkey: 'd' * 64, + addedAt: DateTime.fromMillisecondsSinceEpoch(0), + ), + ], + ); + + expect(firstGrant.endpointGrant, 'new-grant'); + expect(secondGrant.endpointGrant, 'new-grant'); + expect(enrollmentArguments, [ + { + 'relayUrl': 'wss://relay.example/', + 'gatewayUrl': 'https://gateway-one.example/', + }, + { + 'relayUrl': 'wss://relay.example/', + 'gatewayUrl': 'https://gateway-two.example/', + }, + ]); + expect(methods, [ + 'enrollPush', + 'endpointGrants', + 'enrollPush', + 'endpointGrants', + 'saveCommunitySnapshot', + ]); + expect(snapshotArguments, [ + { + 'communities': [ + { + 'id': 'community-id', + 'name': 'Community', + 'relayUrl': 'wss://relay.example/', + 'pubkey': 'd' * 64, + 'pushSubscriptionState': { + 'authority': 'desired', + 'desired': [], + }, + }, + ], + 'signingKeys': {}, + }, + ]); + expect(pushEndpointGrants.value.single.endpointGrant, 'new-grant'); + }, + ); + + test('development push gateway matches the compiled configuration', () { + const expectedGateway = String.fromEnvironment( + 'BUZZ_PUSH_GATEWAY_URL', + defaultValue: 'https://push.buzz.xyz', + ); + expect(Env.pushGatewayUrl, expectedGateway); + }); + + test('exposes APNs registration failure', () async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + const MethodCall('apnsRegistrationFailed', {'message': 'denied'}), + ), + (_) {}, + ); + expect(apnsRegistrationError.value, 'denied'); + }); + + test('routes a warm notification response with opaque IDs', () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + MethodCall('notificationOpened', { + 'eventId': 'MESSAGE-ID', + 'communityId': 'community-id', + 'channelId': 'CHANNEL/GENERAL', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'handled'); + expect( + pendingPushNotificationLink.value, + MessageDeepLink( + communityId: 'community-id', + channelId: 'CHANNEL/GENERAL', + messageId: 'MESSAGE-ID', + ), + ); + }); + + test('rejects a notification response with an empty channel ID', () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + MethodCall('notificationOpened', { + 'eventId': 'message-id', + 'communityId': 'community-id', + 'channelId': '', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'ignored'); + expect(pendingPushNotificationLink.value, isNull); + }); + + test( + 'rejects a notification response with a non-string message ID', + () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + MethodCall('notificationOpened', { + 'eventId': 42, + 'communityId': 'community-id', + 'channelId': 'channel-id', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'ignored'); + expect(pendingPushNotificationLink.value, isNull); + }, + ); + + test('rejects a notification response with an absent message ID', () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + const MethodCall('notificationOpened', { + 'communityId': 'community-id', + 'channelId': 'channel-id', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'ignored'); + expect(pendingPushNotificationLink.value, isNull); + }); + + test('pulls a cold-start notification response from native iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'takePendingNotificationResponse'); + return { + 'eventId': 'b' * 64, + 'communityId': 'community-id', + 'channelId': '123e4567-e89b-42d3-a456-426614174000', + }; + }); + + await syncPendingBuzzPushNotificationResponse(); + + expect( + pendingPushNotificationLink.value, + MessageDeepLink( + communityId: 'community-id', + channelId: '123e4567-e89b-42d3-a456-426614174000', + messageId: 'b' * 64, + ), + ); + }); +} + +Map _grantMap(String endpointGrant) => { + 'relayOrigin': 'wss://relay.example', + 'relayPubkey': 'a' * 64, + '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_presentation_cache_test.dart b/mobile/test/shared/push/push_presentation_cache_test.dart new file mode 100644 index 00000000000..21ec3c8c6cc --- /dev/null +++ b/mobile/test/shared/push/push_presentation_cache_test.dart @@ -0,0 +1,148 @@ +import 'package:buzz/shared/push/push_presentation_cache.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + const secretKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + + test('accepts a valid signed profile event', () { + final signed = nostr.Event.from( + kind: 0, + content: '{"display_name":"Alice"}', + secretKey: secretKey, + createdAt: 1700000000, + ); + + expect( + isVerifiedPushPresentationEvent(NostrEvent.fromJson(signed.toMap())), + isTrue, + ); + }); + + test('accepts opaque channel IDs in a valid signed metadata event', () { + final signed = nostr.Event.from( + kind: 39000, + content: '', + tags: const [ + ['d', 'channel/general:v5'], + ['name', 'General'], + ], + secretKey: secretKey, + createdAt: 1700000000, + ); + + expect( + isVerifiedPushPresentationEvent(NostrEvent.fromJson(signed.toMap())), + isTrue, + ); + }); + + test('accepts a valid signed channel membership snapshot', () { + final signed = nostr.Event.from( + kind: 39002, + content: '', + tags: const [ + ['d', 'channel/general:v5'], + [ + 'p', + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'member', + ], + ], + secretKey: secretKey, + createdAt: 1700000000, + ); + + expect( + isVerifiedPushPresentationEvent(NostrEvent.fromJson(signed.toMap())), + isTrue, + ); + }); + + test( + 'bounded channel selection keeps metadata paired with selected rosters', + () { + NostrEvent signedChannelEvent(int kind, String channelID, int createdAt) { + final signed = nostr.Event.from( + kind: kind, + content: '', + tags: [ + ['d', channelID], + if (kind == 39002) + [ + 'p', + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + ], + ], + secretKey: secretKey, + createdAt: createdAt, + ); + return NostrEvent.fromJson(signed.toMap()); + } + + final batch = selectBoundedPushChannelEvents( + [ + signedChannelEvent(39000, 'channel-0', 100), + signedChannelEvent(39000, 'channel-1', 300), + signedChannelEvent(39000, 'channel-2', 200), + ], + [ + signedChannelEvent(39002, 'channel-0', 300), + signedChannelEvent(39002, 'channel-1', 200), + signedChannelEvent(39002, 'channel-2', 100), + ], + maximumChannels: 2, + ); + + expect(batch.metadata.map((event) => event.getTagValue('d')).toSet(), { + 'channel-0', + 'channel-1', + }); + expect(batch.membership.map((event) => event.getTagValue('d')).toSet(), { + 'channel-0', + 'channel-1', + }); + }, + ); + + test('rejects changed content and malformed signatures', () { + final signed = nostr.Event.from( + kind: 0, + content: '{"name":"Alice"}', + secretKey: secretKey, + createdAt: 1700000000, + ); + final event = NostrEvent.fromJson(signed.toMap()); + + expect( + isVerifiedPushPresentationEvent( + NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: '{"name":"Mallory"}', + sig: event.sig, + ), + ), + isFalse, + ); + expect( + isVerifiedPushPresentationEvent( + NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: '00', + ), + ), + isFalse, + ); + }); +} diff --git a/mobile/test/shared/push/push_snapshot_test.dart b/mobile/test/shared/push/push_snapshot_test.dart new file mode 100644 index 00000000000..730edf8e3fc --- /dev/null +++ b/mobile/test/shared/push/push_snapshot_test.dart @@ -0,0 +1,28 @@ +import 'package:buzz/shared/push/push_snapshot.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('push community snapshot carries explicit subscription authority', () { + final subscription = buildDesiredBuzzPushSubscriptions( + myPubkey: 'a' * 64, + ).single; + final snapshot = BuzzPushCommunitySnapshot( + id: 'community', + name: 'Team', + relayUrl: 'https://relay.example.com', + pubkey: 'a' * 64, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ), + ); + + final decoded = BuzzPushCommunitySnapshot.fromJson(snapshot.toJson()); + + expect(decoded.toJson(), snapshot.toJson()); + expect( + decoded.pushSubscriptionState.authority, + BuzzPushLeaseSubscriptionAuthority.desired, + ); + }); +} diff --git a/mobile/test/shared/push/push_subscription_provider_test.dart b/mobile/test/shared/push/push_subscription_provider_test.dart new file mode 100644 index 00000000000..d7571065948 --- /dev/null +++ b/mobile/test/shared/push/push_subscription_provider_test.dart @@ -0,0 +1,66 @@ +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:buzz/shared/push/push_subscription_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + const activeID = '123e4567-e89b-42d3-a456-426614174000'; + const archivedID = '123e4567-e89b-42d3-a456-426614174001'; + const nonMemberID = '123e4567-e89b-42d3-a456-426614174002'; + + test( + 'derives desired subscriptions from nsec, membership, and mute state', + () { + final nsec = nostr.Keys.generate().nsec; + final subscriptions = desiredBuzzPushSubscriptions( + community: Community.create( + name: 'Team', + relayUrl: 'https://relay.example.com', + nsec: nsec, + ), + channels: [ + channel(activeID), + channel(archivedID, archived: true), + channel(nonMemberID, isMember: false), + ], + mutedChannelIds: const [activeID, archivedID], + ); + + expect(subscriptions, isNotNull); + expect(subscriptions, hasLength(1)); + expect(subscriptions!.single.filter.pTags, hasLength(1)); + expect(subscriptions.single.ignore, hasLength(2)); + expect(subscriptions.single.ignore.first.kinds, buzzPushRenderableKinds); + expect(subscriptions.single.ignore.last.hTags, [activeID]); + }, + ); + + test('returns no desired subscriptions without a signing identity', () { + final subscriptions = desiredBuzzPushSubscriptions( + community: Community.create( + name: 'Team', + relayUrl: 'https://relay.example.com', + ), + channels: [channel(activeID)], + mutedChannelIds: const [], + ); + + expect(subscriptions, isNull); + }); +} + +Channel channel(String id, {bool isMember = true, bool archived = false}) => + Channel( + id: id, + name: id, + channelType: 'dm', + visibility: 'open', + description: '', + createdBy: 'author', + createdAt: DateTime(2026), + memberCount: 1, + isMember: isMember, + archivedAt: archived ? DateTime(2026) : null, + ); diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart new file mode 100644 index 00000000000..9c666a9e063 --- /dev/null +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; + +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final me = 'a' * 64; + const channelA = '123e4567-e89b-42d3-a456-426614174000'; + const channelB = '123e4567-e89b-42d3-a456-426614174001'; + + test( + 'desired subscription state round-trips with an accepted-state seam', + () { + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: [channelB, channelA], + ); + final state = BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ); + + final decoded = BuzzPushLeaseSubscriptionState.fromJson( + jsonDecode(jsonEncode(state.toJson())) as Map, + ); + + expect(decoded.authority, BuzzPushLeaseSubscriptionAuthority.desired); + expect(decoded.accepted, isNull); + expect(decoded.toJson(), state.toJson()); + expect(decoded.authoritative, hasLength(2)); + expect(decoded.authoritative.last.filter.hTags, [channelA, channelB]); + }, + ); + + test('accepted authority requires observed accepted subscriptions', () { + final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; + + expect( + () => BuzzPushLeaseSubscriptionState.fromJson({ + 'authority': 'accepted', + 'desired': [subscription.toJson()], + }), + throwsFormatException, + ); + }); + + test('tracks relay lease and gateway grant generations independently', () { + final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; + final state = + BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted( + subscriptions: [subscription], + generation: 9, + grantGeneration: 3, + installationId: 'c' * 32, + ); + + final decoded = BuzzPushLeaseSubscriptionState.fromJson(state.toJson()); + expect(decoded.acceptedGeneration, 9); + expect(decoded.acceptedGrantGeneration, 3); + + final migrated = BuzzPushLeaseSubscriptionState.fromJson({ + ...state.toJson()..remove('acceptedGrantGeneration'), + }); + expect(migrated.acceptedGeneration, 9); + expect(migrated.acceptedGrantGeneration, 9); + }); + + test('builds aligned self and unmuted channel subscriptions', () { + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me.toUpperCase(), + channelIds: [channelB, channelA], + mutedChannelIds: [channelB, '123e4567-e89b-42d3-a456-426614174099'], + ); + + expect(subscriptions, hasLength(2)); + expect(subscriptions.first.filter.kinds, buzzPushSelfDirectedKinds); + expect(subscriptions.first.filter.kinds, isNot(contains(7))); + expect(subscriptions.first.filter.pTags, [me]); + expect(subscriptions.last.filter.kinds, buzzPushChannelKinds); + expect(subscriptions.last.filter.hTags, [channelA]); + expect(subscriptions.first.ignore, hasLength(2)); + expect(subscriptions.first.ignore.first.kinds, buzzPushRenderableKinds); + expect(subscriptions.first.ignore.last.hTags, [channelB]); + expect( + subscriptions.first.suppress?.pTagsMax, + buzzPushHellthreadParticipantLimit, + ); + }); + + test('chunks channel subscriptions to relay limits deterministically', () { + final channels = [ + for (var i = 0; i < 51; i++) + '00000000-0000-4000-8000-${i.toString().padLeft(12, '0')}', + ]..shuffle(); + + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: channels, + ); + + expect(subscriptions, hasLength(3)); + expect(subscriptions[1].filter.hTags, hasLength(50)); + expect(subscriptions[2].filter.hTags, hasLength(1)); + expect( + subscriptions[1].filter.hTags, + orderedEquals([...subscriptions[1].filter.hTags!]..sort()), + ); + }); + + test('rejects malformed and unsupported subscription fields', () { + final valid = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + ).single.toJson(); + + expect( + () => buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: const ['not-a-channel'], + ), + throwsFormatException, + ); + + expect( + () => BuzzPushSubscription.fromJson({...valid, 'unexpected': true}), + throwsFormatException, + ); + expect( + () => BuzzPushSubscription.fromJson({ + 'filter': { + 'kinds': [7], + '#p': [me], + }, + 'class': 'default', + }), + throwsFormatException, + ); + expect( + () => BuzzPushSubscription.fromJson({ + 'filter': { + 'kinds': [9], + '#p': ['not-a-pubkey'], + }, + 'class': 'default', + }), + throwsFormatException, + ); + }); +} diff --git a/schema/schema.sql b/schema/schema.sql index 6e14e6be1bf..66208bdc0f5 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -920,7 +920,7 @@ BEGIN -- Keep this allowlist identical to the relay's validated NIP-PL descriptor. -- Centralizing it on the events table covers every durable producer, -- including internal paths that bypass live dispatch. - IF NEW.kind IN (7, 9, 1059, 40007, 46010) THEN + IF NEW.kind IN (9, 40002, 45001, 45003) THEN PERFORM pg_advisory_xact_lock_shared( hashtextextended('buzz_push_gate:' || NEW.community_id::text, 0)); IF EXISTS ( diff --git a/scripts/mobile-worktree-clean.sh b/scripts/mobile-worktree-clean.sh index a64f52a0d16..644737b7e44 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. Production installs -# (com.buzz.buzzMobile / xyz.block.buzz.mobile, no suffix) are never touched: -# only identifiers with a worktree suffix appended after the production id -# are matched. Run `just mobile-clean` (or this script directly); pass -# --dry-run to list what would be removed without uninstalling. +# 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 +# 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="com.buzz.buzzMobile." +ios_prefix="xyz.block.buzz.dogfood.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 176180db1e9..54bd325452a 100755 --- a/scripts/mobile-worktree-overrides.sh +++ b/scripts/mobile-worktree-overrides.sh @@ -70,7 +70,7 @@ case "$android_slug" in [0-9]*) android_slug="w_$android_slug" ;; esac -ios_bundle_id="com.buzz.buzzMobile.${ios_slug}" +ios_bundle_id="xyz.block.buzz.dogfood.mobile.${ios_slug}" android_suffix=".${android_slug}" cat > "$ios_overrides" < dict: + result = subprocess.run( + ["plutil", "-convert", "json", "-o", "-", str(path)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or "no error detail" + raise RuntimeError(f"plutil failed to parse {path}: {detail}") + if not result.stdout: + raise RuntimeError(f"plutil returned no JSON for {path}") + return json.loads(result.stdout) + + +def semantic_rows(project: dict) -> list[str]: + objects = project["objects"] + root = objects[project["rootObject"]] + rows = [] + for target_id in root["targets"]: + target = objects[target_id] + if target.get("isa") != "PBXNativeTarget": + continue + configuration_list = objects[target["buildConfigurationList"]] + for configuration_id in configuration_list["buildConfigurations"]: + configuration = objects[configuration_id] + settings = configuration.get("buildSettings", {}) + base_reference = configuration.get("baseConfigurationReference") + base_path = objects.get(base_reference, {}).get("path", "-") + rows.append( + " ".join( + [ + target.get("name", "-"), + configuration.get("name", "-"), + base_path, + settings.get("DEVELOPMENT_TEAM", "-"), + settings.get("CODE_SIGN_ENTITLEMENTS", "-"), + settings.get("PRODUCT_BUNDLE_IDENTIFIER", "-"), + ] + ) + ) + return sorted(rows) + + +def main() -> int: + path = Path(sys.argv[1]) if len(sys.argv) > 1 else PBXPROJ + try: + actual = semantic_rows(parse_project(path)) + except (KeyError, TypeError, json.JSONDecodeError, RuntimeError) as error: + print(f"FAIL: {error}", file=sys.stderr) + return 1 + + if actual != EXPECTED_ROWS: + print("FAIL: unexpected iOS target configuration semantics", file=sys.stderr) + print("expected:", *EXPECTED_ROWS, sep="\n", file=sys.stderr) + print("actual:", *actual, sep="\n", file=sys.stderr) + return 1 + + print("iOS target configuration semantics match") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-mobile-worktree-overrides.sh b/scripts/test-mobile-worktree-overrides.sh index dccffd2ef2b..d9f0625a8a0 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 = com\.buzz\.buzzMobile\.feature-work-1$' "$ios" \ +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.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" \ @@ -83,7 +83,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 = com\.buzz\.buzzMobile\.feature-work-1$' "$ios" \ +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.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" @@ -123,10 +123,17 @@ grep -q '^applicationIdSuffix=\.w_2fast$' "$wt2/mobile/android/worktree.properti # ── Tracked build files: overrides are debug-only, release stays production ── debug_xcconfig="$repo_root/mobile/ios/Flutter/Debug.xcconfig" release_xcconfig="$repo_root/mobile/ios/Flutter/Release.xcconfig" +push_xcconfig="$repo_root/mobile/ios/Flutter/PushEnabled.xcconfig" +pbxproj="$repo_root/mobile/ios/Runner.xcodeproj/project.pbxproj" +runner_entitlements="$repo_root/mobile/ios/Runner/Runner.entitlements" +runner_push_entitlements="$repo_root/mobile/ios/Runner/RunnerPush.entitlements" gradle="$repo_root/mobile/android/app/build.gradle.kts" manifest="$repo_root/mobile/android/app/src/main/AndroidManifest.xml" plist="$repo_root/mobile/ios/Runner/Info.plist" +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 'WorktreeOverrides.xcconfig' "$debug_xcconfig" \ && pass "Debug.xcconfig includes WorktreeOverrides" \ || fail "Debug.xcconfig must include WorktreeOverrides.xcconfig" @@ -137,15 +144,377 @@ 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 'WorktreeOverrides' "$release_xcconfig" \ && fail "Release.xcconfig must not include WorktreeOverrides.xcconfig" \ || pass "Release.xcconfig does not include WorktreeOverrides" -grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile$' "$release_xcconfig" \ +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.mobile$' "$release_xcconfig" \ && pass "Release.xcconfig keeps the production bundle identifier" \ - || fail "Release.xcconfig must keep BUNDLE_IDENTIFIER = com.buzz.buzzMobile" + || fail "Release.xcconfig must keep BUNDLE_IDENTIFIER = xyz.block.buzz.mobile" grep -q '^APP_DISPLAY_NAME = Buzz$' "$release_xcconfig" \ && pass "Release.xcconfig keeps the production display name" \ || fail "Release.xcconfig must keep APP_DISPLAY_NAME = Buzz" + +# These checks assert declarations in the two tracked xcconfigs only. They do +# not prove resolved build settings. The later gitignored includes +# (WorktreeOverrides.xcconfig and AppOverrides.xcconfig) can override these +# declarations and are explicitly outside this tracked-source assertion. The +# value check and declaration census are complementary: xcconfig is last-wins, +# while the census deliberately flags even a harmless duplicate declaration so +# a human reviews the changed declaration surface. +assert_xcconfig_value() { + # $1: file, $2: anchored value regex, $3: pass/failure description + if grep -qE "$2" "$1"; then + pass "$3" + else + fail "$3" + fi +} + +assert_xcconfig_declaration_count() { + # $1: file, $2: key, $3: expected count, $4: configuration label + local file="$1" key="$2" expected="$3" label="$4" count + count=$(grep -cE "^[[:space:]]*$key([[:space:]]*\[[^]]*\])*[[:space:]]*=" "$file" || true) + if [[ "$count" -eq "$expected" ]]; then + if [[ "$expected" -eq 0 ]]; then + pass "$label $key has no tracked declaration sites" + elif [[ "$expected" -eq 1 ]]; then + pass "$label $key has one tracked declaration site" + else + pass "$label $key has $count tracked declaration sites" + fi + elif [[ "$expected" -eq 0 ]]; then + fail "$label $key has $count tracked declaration sites; expected zero" + elif [[ "$expected" -eq 1 ]]; then + fail "$label $key has $count tracked declaration sites; expected exactly one" + else + fail "$label $key has $count tracked declaration sites; expected $expected" + fi +} + +assert_single_xcconfig_declaration() { + # $1: file, $2: key, $3: configuration label + assert_xcconfig_declaration_count "$1" "$2" 1 "$3" +} + +for config in "$debug_xcconfig" "$release_xcconfig"; do + assert_xcconfig_value "$config" '^BUZZ_PUSH_ENABLED = NO$' \ + "$(basename "$config") defaults push capability off" + assert_xcconfig_value "$config" \ + '^BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements$' \ + "$(basename "$config") uses the push-free Runner entitlements" + assert_xcconfig_value "$config" \ + '^EXCLUDED_SOURCE_FILE_NAMES = NotificationService\.appex PushNativeState\.swift PushEndpointGrantStore\.swift PushPresentationCacheBridge\.swift$' \ + "$(basename "$config") excludes native push sources and extension product" +done + +assert_xcconfig_value "$push_xcconfig" '^BUZZ_PUSH_ENABLED = YES$' \ + "PushEnabled explicitly enables the capability" +assert_xcconfig_value "$push_xcconfig" \ + '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile$' \ + "PushEnabled selects the internal dogfood bundle" +assert_xcconfig_value "$push_xcconfig" \ + '^BUZZ_IOS_PUSH_ENVIRONMENT = production$' \ + "PushEnabled uses production APNs transport for distribution" +assert_xcconfig_value "$push_xcconfig" \ + '^BUZZ_APP_ATTEST_ENVIRONMENT = production$' \ + "PushEnabled uses production App Attest" +assert_xcconfig_value "$push_xcconfig" \ + '^BUZZ_APP_GROUP_IDENTIFIER = group\.\$\(BUNDLE_IDENTIFIER\)$' \ + "PushEnabled derives the App Group from the dogfood bundle" +assert_xcconfig_value "$push_xcconfig" \ + '^BUZZ_KEYCHAIN_ACCESS_GROUP = \$\(BUNDLE_IDENTIFIER\)$' \ + "PushEnabled derives the Keychain access group from the dogfood bundle" +assert_xcconfig_value "$push_xcconfig" \ + '^BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/RunnerPush\.entitlements$' \ + "PushEnabled selects push-capable Runner entitlements" +assert_xcconfig_value "$push_xcconfig" \ + '^SWIFT_ACTIVE_COMPILATION_CONDITIONS = \$\(inherited\) BUZZ_PUSH_ENABLED$' \ + "PushEnabled compiles the native push bridge" +assert_xcconfig_value "$push_xcconfig" '^EXCLUDED_SOURCE_FILE_NAMES =$' \ + "PushEnabled restores the extension product and native push sources" +assert_xcconfig_value "$push_xcconfig" \ + '^DART_DEFINES = \$\(inherited\),QlVaWl9QVVNIX0VOQUJMRUQ9dHJ1ZQ==$' \ + "PushEnabled compiles the Dart push bootstrap" +assert_xcconfig_value "$release_xcconfig" \ + '^CODE_SIGN_STYLE = Automatic$' \ + "Release code signing style is declared as automatic" +assert_xcconfig_value "$release_xcconfig" \ + '^CODE_SIGN_IDENTITY = iPhone Developer$' \ + "Release code signing identity is declared as iPhone Developer" + +for key in BUNDLE_IDENTIFIER BUZZ_PUSH_ENABLED BUZZ_CODE_SIGN_ENTITLEMENTS EXCLUDED_SOURCE_FILE_NAMES; do + assert_single_xcconfig_declaration "$debug_xcconfig" "$key" "Debug" + assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release" +done + +for key in BUZZ_KEYCHAIN_ACCESS_GROUP BUZZ_IOS_PUSH_ENVIRONMENT BUZZ_APP_ATTEST_ENVIRONMENT BUZZ_APP_GROUP_IDENTIFIER; do + assert_xcconfig_declaration_count "$debug_xcconfig" "$key" 0 "Debug" + assert_xcconfig_declaration_count "$release_xcconfig" "$key" 0 "Release" + assert_single_xcconfig_declaration "$push_xcconfig" "$key" "PushEnabled" +done + +for key in CODE_SIGN_STYLE CODE_SIGN_IDENTITY; do + assert_xcconfig_declaration_count "$debug_xcconfig" "$key" 0 "Debug" + assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release" +done + +assert_xcconfig_declaration_count \ + "$debug_xcconfig" PROVISIONING_PROFILE_SPECIFIER 0 "Debug" +assert_xcconfig_declaration_count \ + "$release_xcconfig" PROVISIONING_PROFILE_SPECIFIER 0 "Release" +assert_single_xcconfig_declaration "$debug_xcconfig" APP_DISPLAY_NAME "Debug" +assert_single_xcconfig_declaration "$release_xcconfig" APP_DISPLAY_NAME "Release" + +# SWIFT_ACTIVE_COMPILATION_CONDITIONS is checked separately from the closed +# identity census above. This is a tracked-source assertion only; resolved Xcode +# build settings are intentionally outside this Linux-compatible test. +assert_xcconfig_value "$debug_xcconfig" \ + '^SWIFT_ACTIVE_COMPILATION_CONDITIONS = \$\(inherited\) DEBUG$' \ + "Debug Swift compilation conditions inherit DEBUG" + +debug_swift_condition_count=$(grep -cE \ + '^[[:space:]]*SWIFT_ACTIVE_COMPILATION_CONDITIONS([[:space:]]*\[[^]]*\])*[[:space:]]*=' \ + "$debug_xcconfig" || true) +if [[ "$debug_swift_condition_count" -eq 1 ]]; then + pass "Debug SWIFT_ACTIVE_COMPILATION_CONDITIONS has one tracked declaration site" +else + fail "Debug SWIFT_ACTIVE_COMPILATION_CONDITIONS has $debug_swift_condition_count tracked declaration sites; expected exactly one" +fi + +release_swift_condition_count=$(grep -cE \ + '^[[:space:]]*SWIFT_ACTIVE_COMPILATION_CONDITIONS([[:space:]]*\[[^]]*\])*[[:space:]]*=' \ + "$release_xcconfig" || true) +if [[ "$release_swift_condition_count" -eq 0 ]]; then + pass "Release SWIFT_ACTIVE_COMPILATION_CONDITIONS has no tracked declaration sites" +else + fail "Release SWIFT_ACTIVE_COMPILATION_CONDITIONS has $release_swift_condition_count tracked declaration sites; expected zero" +fi + +grep -q 'aps-environment' "$runner_entitlements" \ + && fail "push-free Runner entitlements must not contain aps-environment" \ + || pass "push-free Runner entitlements omit aps-environment" + +# Split the retired identifiers so the regression test does not match itself. +retired_bundle_id='com.buzz.buzz'"Mobile" +if git -C "$repo_root" grep -q -F "$retired_bundle_id"; then + fail "tracked files must not retain the retired iOS bundle identifier" +else + pass "tracked files do not retain the retired iOS bundle identifier" +fi +grep -q 'com.apple.developer.devicecheck.appattest-environment' "$runner_push_entitlements" \ + && pass "push-enabled Runner uses the App Attest entitlement key accepted by Apple" \ + || fail "push-enabled Runner must use com.apple.developer.devicecheck.appattest-environment" +retired_entitlement_key='com.apple.developer.app-attest.'"environment" +if grep -q "$retired_entitlement_key" "$runner_push_entitlements"; then + fail "push-enabled Runner must not retain the invalid App Attest entitlement key" +else + pass "push-enabled Runner omits the invalid App Attest entitlement key" +fi + +duplicate_pbx_object_ids=$(awk ' + # This bounded source-level smoke check recognizes the current two-tab + # object-key spellings. It is not a general OpenStep uniqueness check: + # measured exclusions include a comment before the key, a presentation + # comment spanning lines, and one-tab indentation (jb_b1/jb_b2/jb_b6). + # The macOS semantic check below owns their resolved build consequences. + function decomment(s, head, tailpart) { + while (match(s, /\/\*/)) { + head = substr(s, 1, RSTART - 1) + tailpart = substr(s, RSTART + 2) + if (!match(tailpart, /\*\//)) return head " " + s = head " " substr(tailpart, RSTART + RLENGTH) + } + return s + } + + /^\t\t/ { + line = decomment($0) + if (match(line, /^\t\t"?[[:alnum:]]+"?[[:space:]]*=/)) { + object_id = substr(line, RSTART, RLENGTH) + sub(/^\t\t"?/, "", object_id) + sub(/"?[[:space:]]*=$/, "", object_id) + if (++object_id_count[object_id] == 2) print object_id + } + } +' "$pbxproj" | sort) +if [[ -n "$duplicate_pbx_object_ids" ]]; then + fail "recognized iOS project object identifiers repeat: $(printf '%s\n' "$duplicate_pbx_object_ids" | paste -sd ' ' -)" +else + pass "recognized iOS project object identifiers do not repeat" +fi + +signing_map=$(awk ' + # PBX comments are separators, not text: strip them before parsing any + # object so a comment cannot hide a duplicate key from the ambiguity count. + function decomment(s, head, tailpart) { + while (match(s, /\/\*/)) { + head = substr(s, 1, RSTART - 1) + tailpart = substr(s, RSTART + 2) + if (!match(tailpart, /\*\//)) { return head " " } + s = head " " substr(tailpart, RSTART + RLENGTH) + } + return s + } + + FNR == 1 { pass++ } + + # Pass 1 indexes xcconfig paths and follows each PBXNativeTarget to its + # actual configuration-list object. Target names come from object fields, + # not presentation comments. + pass == 1 { + if (/isa[[:space:]]*=[[:space:]]*PBXFileReference/ && /\.xcconfig/) { + declaration = decomment($0) + if (match(declaration, /=[[:space:]]*\{[[:space:]]*isa[[:space:]]*=[[:space:]]*PBXFileReference[[:space:]]*;/)) { + declaration = substr(declaration, RSTART + RLENGTH) + } else { + declaration = "" + } + sub(/\}.*/, "", declaration) + xcconfig_path = "MISSING_PATH" + rest = declaration + path_matches = 0 + while (match(rest, /(^|;)[[:space:]]*path[[:space:]]*=[[:space:]]*[^;]+/)) { + candidate = substr(rest, RSTART, RLENGTH) + rest = substr(rest, RSTART + RLENGTH) + sub(/^;?[[:space:]]*path[[:space:]]*=[[:space:]]*/, "", candidate) + gsub(/"/, "", candidate) + sub(/[[:space:]]+$/, "", candidate) + path_matches++ + xcconfig_path = candidate + } + # More than one `path =` in one object means a decoy (a quoted value or + # an embedded comment) is shadowing the real key. Never guess which one + # the build uses: fail the row loudly instead. + if (path_matches > 1) xcconfig_path = "AMBIGUOUS_PATH" + xcconfig_paths[$1] = xcconfig_path + } + + if (/\/\* Begin PBXNativeTarget section \*\//) { + in_native_targets = 1 + next + } + if (/\/\* End PBXNativeTarget section \*\//) { + in_native_targets = 0 + next + } + if (!in_native_targets) next + + if (/^\t\t[^[:space:]]+ .* = \{$/) { + native_target_id = $1 + native_target_name = "" + native_target_list = "" + next + } + if (native_target_id != "" && /^\t\t\tname = /) { + native_target_name = $0 + sub(/^.*= */, "", native_target_name) + sub(/;.*/, "", native_target_name) + gsub(/"/, "", native_target_name) + next + } + if (native_target_id != "" && /^\t\t\tbuildConfigurationList = /) { + native_target_list = $3 + next + } + if (native_target_id != "" && /^\t\t\};/) { + if (native_target_list != "") { + if (native_target_name == "") native_target_name = "UNNAMED:" native_target_id + if (native_target_list in list_owners) { + list_owners[native_target_list] = "DUPLICATE:" list_owners[native_target_list] "+" native_target_name + } else { + list_owners[native_target_list] = native_target_name + } + } + native_target_id = "" + } + next + } + + # Pass 2 maps build-configuration object IDs through only those lists that + # real native targets own. PBXProject and other unowned lists are ignored. + pass == 2 { + if (/\/\* Begin XCConfigurationList section \*\//) { + in_configuration_lists = 1 + next + } + if (/\/\* End XCConfigurationList section \*\//) { + in_configuration_lists = 0 + next + } + if (!in_configuration_lists) next + + if (/^\t\t[^[:space:]]+ .* = \{$/) { + configuration_list_id = $1 + configuration_list_owner = configuration_list_id in list_owners ? list_owners[configuration_list_id] : "" + next + } + if (/buildConfigurations = \(/) { + in_list_configurations = 1 + next + } + if (in_list_configurations && /\);/) { + in_list_configurations = 0 + next + } + if (in_list_configurations && $1 ~ /^[[:alnum:]]+$/ && configuration_list_owner != "") { + if ($1 in targets) targets[$1] = "DUPLICATE:" targets[$1] "+" configuration_list_owner + else targets[$1] = configuration_list_owner + } + next + } + + # Pass 3 emits one row for each team-bearing build configuration. + !in_build_configuration && /\/\* (Debug|Release|Profile) \*\/ = \{/ { + in_build_configuration = 1 + configuration_id = $1 + configuration = $3 + base_configuration = "NONE" + team = "" + entitlements = "NONE" + depth = 0 + } + + in_build_configuration { + if (/baseConfigurationReference =/) { + base_configuration = $3 in xcconfig_paths ? xcconfig_paths[$3] : "UNRESOLVED:" $3 + } + if (/DEVELOPMENT_TEAM =/) { + team = $0 + sub(/^.*= */, "", team) + sub(/;.*/, "", team) + } + if (/CODE_SIGN_ENTITLEMENTS =/) { + entitlements = $0 + sub(/^.*= */, "", entitlements) + sub(/;.*/, "", entitlements) + } + + depth += gsub(/\{/, "{") - gsub(/\}/, "}") + if (depth == 0) { + if (team != "") { + target_name = configuration_id in targets ? targets[configuration_id] : "UNMAPPED:" configuration_id + print target_name, configuration, base_configuration, team, entitlements + } + in_build_configuration = 0 + } + } +' "$pbxproj" "$pbxproj" "$pbxproj" | sort) +expected_signing_map=$(printf '%s\n' \ + 'NotificationService Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ + 'NotificationService Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ + 'NotificationService Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \ + 'Runner Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"' \ + 'Runner Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"' \ + 'Runner Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"') +if [[ "$signing_map" == "$expected_signing_map" ]]; then + pass "Runner and NotificationService signing settings match each build configuration" +else + fail "unexpected iOS signing map: $signing_map" +fi grep -q '$(APP_DISPLAY_NAME)' "$plist" \ && pass "Info.plist display name resolves from build settings" \ || fail "Info.plist CFBundleDisplayName must be \$(APP_DISPLAY_NAME)"