diff --git a/.env.example b/.env.example index a6740f7a7d8..6ed7cdcaedc 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 + # Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and # authenticated desktop clients use this relay as the metadata/search proxy. # Keep the real value in your deployment's secret manager; never commit it. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd2949492f6..5f86e7d33ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -934,6 +934,20 @@ 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 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 5e9c5aade3d..ae364d57000 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1234,7 +1234,6 @@ dependencies = [ "metrics-exporter-prometheus", "minicbor", "nostr 0.44.7", - "p256", "proptest", "rand 0.10.1", "reqwest 0.13.4", @@ -1915,12 +1914,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" @@ -2073,22 +2066,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" @@ -2106,9 +2083,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]] @@ -2176,7 +2151,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", - "subtle", ] [[package]] @@ -2622,21 +2596,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" @@ -2673,27 +2632,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" @@ -2896,16 +2834,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" @@ -3330,17 +3258,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" @@ -3683,9 +3600,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ - "subtle", "typenum", - "zeroize", ] [[package]] @@ -6600,19 +6515,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" @@ -7097,33 +6999,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" @@ -8027,16 +7902,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" @@ -8396,20 +8261,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" @@ -8833,10 +8684,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" @@ -11513,17 +11360,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 6c7740bc7ac..838f6e7d47a 100644 --- a/Justfile +++ b/Justfile @@ -314,9 +314,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 9df02c8abf9..3ab5a93077d 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; @@ -645,7 +646,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] @@ -1109,6 +1110,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 3aa6cd9b3fe..b00e8e4074b 100644 --- a/crates/buzz-db/src/push.rs +++ b/crates/buzz-db/src/push.rs @@ -57,7 +57,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", @@ -160,6 +160,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, } @@ -234,11 +236,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); crate::observability::observe_advisory_lock( crate::observability::LockType::PushGate, sqlx::query("SELECT pg_advisory_xact_lock($1)") @@ -614,10 +622,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 @@ -1083,7 +1091,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()) @@ -1111,7 +1120,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 @@ -1274,6 +1284,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..06376c02dc5 100644 --- a/crates/buzz-push-gateway/Cargo.toml +++ b/crates/buzz-push-gateway/Cargo.toml @@ -29,9 +29,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/migrations/0003_challenge_issuance_quota.sql b/crates/buzz-push-gateway/migrations/0003_challenge_issuance_quota.sql new file mode 100644 index 00000000000..cc8222f6c6d --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0003_challenge_issuance_quota.sql @@ -0,0 +1,4 @@ +-- The unauthenticated challenge route applies a deployment-global rolling +-- issuance quota. Keep its count query bounded as challenge volume grows. +CREATE INDEX push_gateway_challenges_created_at + ON push_gateway_challenges (created_at); diff --git a/crates/buzz-push-gateway/migrations/0004_dogfood_only_profile.sql b/crates/buzz-push-gateway/migrations/0004_dogfood_only_profile.sql new file mode 100644 index 00000000000..2274219d6ef --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0004_dogfood_only_profile.sql @@ -0,0 +1,16 @@ +-- The internal MVP now exposes only the dogfood application profile. Retire +-- dormant App Store authority before narrowing the server-owned registry. +DELETE FROM push_gateway_delegations +WHERE installation_id IN ( + SELECT id FROM push_gateway_installations + WHERE app_profile = 'buzz-ios-app-store' +); + +DELETE FROM push_gateway_installations +WHERE app_profile = 'buzz-ios-app-store'; + +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 = 'buzz-ios-dogfood'); 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..df655e23d88 100644 --- a/crates/buzz-push-gateway/src/app_attest.rs +++ b/crates/buzz-push-gateway/src/app_attest.rs @@ -6,7 +6,6 @@ use byteorder::{BigEndian, ByteOrder}; use sha2::{Digest, Sha256}; use thiserror::Error; -const MAX_ATTESTATION_BYTES: usize = 16 * 1024; const MAX_ASSERTION_BYTES: usize = 1024; const APPLE_APP_ATTEST_ROOT_PEM_SHA256: [u8; 32] = [ 0xc7, 0x78, 0xd0, 0x9a, 0xc3, 0x41, 0xf7, 0xfd, 0x9f, 0x8f, 0x3b, 0x19, 0xe2, 0xb8, 0x15, 0xaf, @@ -57,7 +56,7 @@ impl AppAttestVerifier { let cbor = STANDARD .decode(attestation_b64) .map_err(|_| AppAttestError::Invalid)?; - if cbor.is_empty() || cbor.len() > MAX_ATTESTATION_BYTES { + if cbor.is_empty() || cbor.len() > crate::model::MAX_APP_ATTESTATION_BYTES { return Err(AppAttestError::Invalid); } let challenge = std::str::from_utf8(client_data).map_err(|_| AppAttestError::Invalid)?; @@ -138,3 +137,213 @@ 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 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, + app_id: String, + challenge: String, + aaguid: 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()); + 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() + ); + } +} diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 36c220885cd..ee172d5114d 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -15,9 +15,16 @@ use uuid::Uuid; pub struct Challenge { pub id: Uuid, pub value: [u8; 32], + pub created_at: i64, pub expires_at: i64, } +/// Challenge issuance is intentionally bounded inside the durable authority +/// store so the public unauthenticated route cannot amplify database writes +/// across gateway replicas. +pub(crate) const CHALLENGE_QUOTA_WINDOW_SECONDS: i64 = 60; +pub(crate) const CHALLENGE_QUOTA_MAX_REQUESTS: usize = 600; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewInstallation { pub id: Uuid, @@ -96,6 +103,8 @@ pub enum DeliveryDisposition { pub enum AuthorityError { #[error("authority state rejected the request")] Rejected, + #[error("authority request rate exceeded")] + RateLimited, #[error("authority store unavailable")] Unavailable, } @@ -116,6 +125,7 @@ pub trait AuthorityStore: Send + Sync { async fn create_installation( &self, installation: NewInstallation, + now: i64, ) -> Result<(), AuthorityError>; async fn installation(&self, id: Uuid, now: i64) -> Result; async fn advance_assertion_counter( @@ -133,11 +143,13 @@ pub trait AuthorityStore: Send + Sync { token_ciphertext: Vec, token_fingerprint: [u8; 32], ) -> Result<(), AuthorityError>; + /// Revoke an active delegation only when `expected_generation` is current, + /// retaining that generation as the replacement watermark. async fn revoke_delegation( &self, installation_id: Uuid, relay_pubkey: &str, - new_generation: i64, + expected_generation: i64, ) -> Result<(), AuthorityError>; async fn revoke_installation( &self, @@ -202,6 +214,17 @@ impl AuthorityStore for MemoryAuthorityStore { async fn put_challenge(&self, challenge: Challenge) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let window_start = challenge + .created_at + .saturating_sub(CHALLENGE_QUOTA_WINDOW_SECONDS); + if s.challenges + .values() + .filter(|existing| existing.created_at >= window_start) + .count() + >= CHALLENGE_QUOTA_MAX_REQUESTS + { + return Err(AuthorityError::RateLimited); + } if s.challenges.insert(challenge.id, challenge).is_some() { return Err(AuthorityError::Rejected); } @@ -222,13 +245,43 @@ impl AuthorityStore for MemoryAuthorityStore { Ok(()) } - async fn create_installation(&self, n: NewInstallation) -> Result<(), AuthorityError> { + async fn create_installation( + &self, + n: NewInstallation, + now: i64, + ) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; let token_key = (n.profile, n.token_fingerprint); - if s.installations.contains_key(&n.id) || s.token_owners.contains_key(&token_key) { - // Token possession alone never supersedes a live installation. + if s.installations.contains_key(&n.id) { + return Err(AuthorityError::Rejected); + } + let replaced = s + .installations + .values() + .filter(|installation| { + installation.app_attest_key_id == n.app_attest_key_id + || (installation.profile == n.profile + && installation.token_fingerprint == n.token_fingerprint) + }) + .map(|installation| installation.id) + .collect::>(); + if replaced.iter().any(|id| { + s.installations + .get(id) + .is_some_and(|installation| !installation.revoked && installation.expires_at >= now) + }) { + // App identity and token possession never supersede a live installation. return Err(AuthorityError::Rejected); } + for id in replaced { + if let Some(old) = s.installations.remove(&id) { + s.token_owners.remove(&(old.profile, old.token_fingerprint)); + } + s.delegations + .retain(|(installation_id, _), _| *installation_id != id); + s.delegation_ids + .retain(|_, (installation_id, _)| *installation_id != id); + } s.token_owners.insert(token_key, n.id); s.installations.insert( n.id, @@ -281,15 +334,14 @@ impl AuthorityStore for MemoryAuthorityStore { async fn upsert_delegation(&self, d: Delegation) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; - let i = s + let installation = s .installations .get(&d.installation_id) .ok_or(AuthorityError::Rejected)?; - if i.revoked - || i.endpoint_epoch != d.endpoint_epoch + if installation.revoked + || installation.endpoint_epoch != d.endpoint_epoch || d.generation < 1 || d.not_before >= d.expires_at - || d.expires_at > i.expires_at { return Err(AuthorityError::Rejected); } @@ -301,6 +353,11 @@ impl AuthorityStore for MemoryAuthorityStore { { return Err(AuthorityError::Rejected); } + let installation = s + .installations + .get_mut(&d.installation_id) + .ok_or(AuthorityError::Rejected)?; + installation.expires_at = installation.expires_at.max(d.expires_at); s.delegation_ids.insert(d.id, key.clone()); s.delegations.insert(key, d); Ok(()) @@ -348,7 +405,7 @@ impl AuthorityStore for MemoryAuthorityStore { &self, id: Uuid, relay: &str, - generation: i64, + expected_generation: i64, ) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; let key = (id, relay.to_owned()); @@ -356,10 +413,9 @@ impl AuthorityStore for MemoryAuthorityStore { .delegations .get_mut(&key) .ok_or(AuthorityError::Rejected)?; - if generation <= old.generation { + if old.revoked || expected_generation != old.generation { return Err(AuthorityError::Rejected); } - old.generation = generation; old.revoked = true; Ok(()) } @@ -514,17 +570,20 @@ mod tests { async fn store() -> MemoryAuthorityStore { let store = MemoryAuthorityStore::default(); store - .create_installation(NewInstallation { - id: Uuid::from_u128(1), - app_attest_key_id: vec![1], - app_attest_public_key: vec![2; 33], - assertion_counter: 0, - profile: AppProfile::BuzzIosProduction, - token_ciphertext: vec![3], - token_fingerprint: [4; 32], - endpoint_epoch: 1, - expires_at: 2_000, - }) + .create_installation( + NewInstallation { + id: Uuid::from_u128(1), + app_attest_key_id: vec![1], + app_attest_public_key: vec![2; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![3], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at: 2_000, + }, + 1_000, + ) .await .unwrap(); store @@ -543,6 +602,133 @@ mod tests { store } + #[tokio::test] + async fn challenge_issuance_is_bounded_per_window() { + let store = MemoryAuthorityStore::default(); + for offset in 0..CHALLENGE_QUOTA_MAX_REQUESTS { + store + .put_challenge(Challenge { + id: Uuid::from_u128(offset as u128 + 1), + value: [offset as u8; 32], + created_at: 1_000, + expires_at: 1_300, + }) + .await + .expect("requests within the quota are admitted"); + } + assert_eq!( + store + .put_challenge(Challenge { + id: Uuid::new_v4(), + value: [0; 32], + created_at: 1_000, + expires_at: 1_300, + }) + .await, + Err(AuthorityError::RateLimited) + ); + store + .put_challenge(Challenge { + id: Uuid::new_v4(), + value: [0; 32], + created_at: 1_061, + expires_at: 1_361, + }) + .await + .expect("quota reopens after the rolling window"); + } + + #[tokio::test] + async fn authenticated_delegation_renews_installation_lifetime() { + let store = store().await; + store + .upsert_delegation(Delegation { + id: Uuid::from_u128(3), + installation_id: Uuid::from_u128(1), + relay_pubkey: "11".repeat(32), + endpoint_epoch: 1, + generation: 2, + not_before: 1_900, + expires_at: 2_500, + revoked: false, + }) + .await + .expect("new delegation renews its installation"); + assert_eq!( + store + .installation(Uuid::from_u128(1), 2_400) + .await + .expect("renewed installation remains live") + .expires_at, + 2_500 + ); + + assert_eq!( + store + .upsert_delegation(Delegation { + id: Uuid::from_u128(4), + installation_id: Uuid::from_u128(1), + relay_pubkey: "11".repeat(32), + endpoint_epoch: 1, + generation: 2, + not_before: 2_000, + expires_at: 3_000, + revoked: false, + }) + .await, + Err(AuthorityError::Rejected) + ); + assert_eq!( + store.installation(Uuid::from_u128(1), 2_600).await, + Err(AuthorityError::Rejected), + "a rejected delegation must not extend installation authority" + ); + } + + #[tokio::test] + async fn expired_installation_can_be_replaced_but_live_installation_cannot() { + let store = store().await; + let replacement = |id| NewInstallation { + id, + app_attest_key_id: vec![1], + app_attest_public_key: vec![5; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![6], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at: 3_000, + }; + + assert_eq!( + store + .create_installation(replacement(Uuid::from_u128(5)), 1_999) + .await, + Err(AuthorityError::Rejected) + ); + store + .create_installation(replacement(Uuid::from_u128(5)), 2_001) + .await + .expect("expired token and App Attest ownership can be replaced"); + assert!(store.installation(Uuid::from_u128(1), 2_001).await.is_err()); + assert!(store.installation(Uuid::from_u128(5), 2_001).await.is_ok()); + assert!(store + .authorize_delivery( + Uuid::from_u128(2), + &"11".repeat(32), + 1, + 1, + &"77".repeat(32), + Uuid::new_v4(), + 2_100, + 60, + 10, + 2_001, + ) + .await + .is_err()); + } + #[tokio::test] async fn retry_releases_request_id_but_burns_auth_event() { let store = store().await; @@ -573,4 +759,69 @@ mod tests { .unwrap(); assert!(admitted(&store, &"33".repeat(32), request).await.is_err()); } + + #[tokio::test] + async fn delegation_revocation_requires_the_current_generation() { + let store = store().await; + + assert_eq!( + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 0) + .await, + Err(AuthorityError::Rejected) + ); + assert_eq!( + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 2) + .await, + Err(AuthorityError::Rejected) + ); + admitted(&store, &"44".repeat(32), Uuid::new_v4()) + .await + .expect("rejected revocations must leave generation 1 active"); + + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 1) + .await + .expect("the current generation can be revoked"); + assert!(admitted(&store, &"55".repeat(32), Uuid::new_v4()) + .await + .is_err()); + + let replacement = |id, generation| Delegation { + id, + installation_id: Uuid::from_u128(1), + relay_pubkey: "11".repeat(32), + endpoint_epoch: 1, + generation, + not_before: 900, + expires_at: 1_500, + revoked: false, + }; + assert_eq!( + store + .upsert_delegation(replacement(Uuid::from_u128(3), 1)) + .await, + Err(AuthorityError::Rejected) + ); + store + .upsert_delegation(replacement(Uuid::from_u128(4), 2)) + .await + .expect("only a strictly newer generation can reactivate the delegation"); + store + .authorize_delivery( + Uuid::from_u128(4), + &"11".repeat(32), + 1, + 2, + &"66".repeat(32), + Uuid::new_v4(), + 1_100, + 60, + 10, + 1_000, + ) + .await + .expect("generation 2 authority is active"); + } } diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index c6194edbcb4..f8485a628de 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -1,11 +1,21 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; -use std::{ - collections::{HashMap, HashSet}, - net::SocketAddr, - path::PathBuf, -}; +use std::{collections::HashMap, net::SocketAddr, path::PathBuf}; use thiserror::Error; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApnsEnvironment { + Production, + Sandbox, +} + +#[derive(Debug, Clone)] +pub struct AppProfileConfig { + pub app_attest_app_id: String, + pub apns_cert_path: PathBuf, + pub apns_topic: String, + pub apns_environment: ApnsEnvironment, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct KeyConfig { pub id: String, @@ -21,19 +31,15 @@ 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, + /// Server-owned dogfood application identity and APNs transport. + pub profile: AppProfileConfig, pub database_url: String, - pub app_attest_app_id: String, pub app_attest_root_cert_path: PathBuf, /// 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,6 +81,34 @@ fn parse_keyring( } Ok(keys) } + +fn parse_profile(e: &HashMap) -> Result { + let app_id_key = "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID"; + let cert_key = "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH"; + let topic_key = "BUZZ_PUSH_DOGFOOD_APNS_TOPIC"; + let environment_key = "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT"; + 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 = PathBuf::from(required(cert_key)?); + let apns_environment = match e.get(environment_key).map(String::as_str) { + None | Some("production") => ApnsEnvironment::Production, + Some("sandbox") => ApnsEnvironment::Sandbox, + Some(_) => return Err(ConfigError::Invalid(environment_key)), + }; + Ok(AppProfileConfig { + app_attest_app_id, + apns_cert_path, + apns_topic, + apns_environment, + }) +} + impl Config { pub fn from_env() -> Result { Self::from_map(&std::env::vars().collect()) @@ -141,45 +175,32 @@ impl Config { bounded_positive("BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS", 10, 86_400)?; let endpoint_quota_max_deliveries = bounded_positive("BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES", 10, 10_000)?; - let enabled_profiles = req(e, "BUZZ_PUSH_ENABLED_PROFILES")? - .split(',') - .map(|profile| match profile { - "buzz-ios-production" => Ok(crate::model::AppProfile::BuzzIosProduction), - "buzz-ios-sandbox" => Ok(crate::model::AppProfile::BuzzIosSandbox), - _ => Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")), - }) - .collect::, _>>()?; - if enabled_profiles.is_empty() { - return Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")); - } + let profile = parse_profile(e)?; + 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"))?; 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, + profile, 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(), 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,7 +208,6 @@ impl Config { #[cfg(test)] mod tests { use super::*; - fn base() -> HashMap { HashMap::from([ ( @@ -215,25 +235,56 @@ mod tests { "2592000".into(), ), ( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-production".into(), + "DATABASE_URL".into(), + "postgres://buzz:test@localhost/buzz".into(), // sadscan:disable np.postgres.1 ), ( - "DATABASE_URL".into(), - "postgres://buzz:test@localhost/buzz".into(), + "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID".into(), + "TEAM.xyz.block.buzz.dogfood.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_BIND_ADDR".into(), "127.0.0.1:8080".into()), + ("BUZZ_PUSH_HEALTH_ADDR".into(), "127.0.0.1:8081".into()), ]) } + #[test] + fn dogfood_profile_requires_server_owned_identity_and_certificate() { + let config = Config::from_map(&base()).unwrap(); + assert_eq!( + config.profile.apns_cert_path, + PathBuf::from("/dogfood-identity.pem") + ); + assert_eq!(config.profile.apns_topic, "xyz.block.buzz.dogfood.mobile"); + + for variable in [ + "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", + "BUZZ_PUSH_DOGFOOD_APNS_TOPIC", + "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", + ] { + 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,8 +306,8 @@ mod tests { "BUZZ_PUSH_PUBLIC_DELIVERY_URL", "https://push.example/v1/deliveries/apns", ), - ("BUZZ_PUSH_APP_ATTEST_APP_ID", ""), - ("BUZZ_PUSH_ENABLED_PROFILES", "unknown-profile"), + ("BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", ""), + ("BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", "staging"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "31536001"), ("BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS", "0"), @@ -279,6 +330,17 @@ 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 malformed_or_empty_keyrings_fail_startup() { for (variable, value) in [ 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..84ad2a42a90 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -23,7 +23,6 @@ use nostr::{ Event, JsonUtil, Timestamp, }; use std::{ - collections::HashSet, sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -33,19 +32,25 @@ use std::{ use tower::limit::ConcurrencyLimitLayer; use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer}; +#[derive(Clone)] +pub struct ProfileRuntime { + pub app_attest: Arc, + pub transport: Arc, +} + #[derive(Clone)] pub struct AppState { pub grant_keyring: Arc, - pub app_attest: Arc, pub authority: Arc, pub token_keyring: Arc, - pub transport: Arc, + /// Server-owned dogfood application identity and APNs transport. The wire + /// profile selector is fixed and App Attest verifies the configured app ID. + pub profile: Arc, pub delivery_url: url::Url, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, pub endpoint_quota_max_deliveries: i64, - pub enabled_profiles: HashSet, pub now: fn() -> i64, pub accepting: Arc, } @@ -83,6 +88,7 @@ fn decode_challenge(value: &str) -> Option<[u8; 32]> { fn authority_error(e: AuthorityError) -> Response { match e { AuthorityError::Rejected => error(StatusCode::NOT_FOUND, "not_authorized"), + AuthorityError::RateLimited => error(StatusCode::TOO_MANY_REQUESTS, "rate_limited"), AuthorityError::Unavailable => { error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable") } @@ -125,6 +131,7 @@ async fn challenge(State(s): State, body: Bytes) -> Response { let c = Challenge { id: uuid::Uuid::new_v4(), value, + created_at: now, expires_at, }; if let Err(e) = s.authority.put_challenge(c.clone()).await { @@ -163,11 +170,13 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Some(v) => v, None => return error(StatusCode::BAD_REQUEST, "invalid_request"), }; + if r.app_profile != AppProfile::BuzzIosDogfood { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } if r.v != WIRE_VERSION || r.endpoint_epoch != 1 || r.expires_at <= now || 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"); } @@ -192,10 +201,11 @@ async fn enroll(State(s): State, body: Bytes) -> Response { }; let verified = match s + .profile .app_attest .verify_attestation(&r.attestation, &r.key_id, signed.as_bytes()) { - Ok(v) => v, + Ok(value) => value, Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), }; if let Err(e) = s @@ -221,7 +231,7 @@ async fn enroll(State(s): State, body: Bytes) -> Response { endpoint_epoch: 1, expires_at: r.expires_at, }; - if let Err(e) = s.authority.create_installation(n).await { + if let Err(e) = s.authority.create_installation(n, now).await { return authority_error(e); } ( @@ -252,9 +262,13 @@ async fn verify_installation_assertion( .installation(installation_id, now) .await .map_err(authority_error)?; + if installation.profile != AppProfile::BuzzIosDogfood { + return Err(error(StatusCode::NOT_FOUND, "not_authorized")); + } let transcript = transcript(domain, signed) .ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?; let verified = s + .profile .app_attest .verify_assertion( assertion, @@ -620,6 +634,11 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> crate::metrics::record_delivery_error("invalid_grant"); return error(StatusCode::NOT_FOUND, "invalid_grant"); } + Err(AuthorityError::RateLimited) => { + crate::metrics::record_admission(crate::metrics::Admission::Rejected); + crate::metrics::record_delivery_error("rate_limited"); + return error(StatusCode::TOO_MANY_REQUESTS, "rate_limited"); + } Err(AuthorityError::Unavailable) => { crate::metrics::record_admission(crate::metrics::Admission::Unavailable); crate::metrics::record_delivery_error("temporarily_unavailable"); @@ -634,7 +653,15 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> .await; return error(StatusCode::NOT_FOUND, "invalid_grant"); } - let profile = permit.authority.profile; + if permit.authority.profile != AppProfile::BuzzIosDogfood { + crate::metrics::record_delivery_error("profile_disabled"); + let _ = s + .authority + .finish_delivery(permit, DeliveryDisposition::Retryable) + .await; + return error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault"); + } + let transport = Arc::clone(&s.profile.transport); let endpoint = match s.token_keyring.open(&permit.authority.token_ciphertext) { Ok(token) => hex::encode(token), Err(_) => { @@ -650,23 +677,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 +725,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"), @@ -735,16 +756,21 @@ pub fn router_with_metrics( state: AppState, metrics_handle: Option, ) -> (Router, Router) { - let public = Router::new() - .route("/v1/installations/challenges", post(challenge)) + let enrollment = Router::new() .route("/v1/installations", post(enroll)) + .layer(RequestBodyLimitLayer::new(MAX_ENROLL_REQUEST_BYTES)); + let standard_requests = Router::new() + .route("/v1/installations/challenges", post(challenge)) .route("/v1/delegations", post(delegate)) .route("/v1/delegations/revoke", post(revoke_delegation)) .route("/v1/installations/endpoint", post(rotate_endpoint)) .route("/v1/installations/revoke", post(revoke_installation)) .route("/v1/deliveries/apns", post(deliver)) + .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BYTES)); + let public = Router::new() + .merge(enrollment) + .merge(standard_requests) .with_state(state.clone()) - .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BYTES)) .layer(ConcurrencyLimitLayer::new(256)) .layer(TimeoutLayer::with_status_code( StatusCode::REQUEST_TIMEOUT, @@ -774,3 +800,247 @@ pub fn router_with_metrics( } (public, health) } + +#[cfg(test)] +mod request_limit_tests { + use super::*; + use crate::{ + authority::MemoryAuthorityStore, + grant::{GrantKey, GrantKeyring}, + token::{TokenKey, TokenKeyring}, + }; + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + struct NeverTransport; + + #[async_trait::async_trait] + impl PushTransport for NeverTransport { + async fn send(&self, _: DeliveryAttempt, _: &str) -> DeliveryOutcome { + panic!("request-size tests never send to APNs") + } + } + + fn fixed_now() -> i64 { + 1_750_000_000 + } + + fn state() -> AppState { + let app_attest = AppAttestVerifier::new( + "TEAMID.xyz.block.buzz.dogfood.mobile".to_owned(), + include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(), + ) + .expect("pinned Apple root fixture"); + AppState { + grant_keyring: Arc::new( + GrantKeyring::new(vec![GrantKey::new("test", &[1; 32]).unwrap()]).unwrap(), + ), + authority: Arc::new(MemoryAuthorityStore::default()), + token_keyring: Arc::new( + TokenKeyring::new(vec![TokenKey::new("test", &[2; 32]).unwrap()]).unwrap(), + ), + profile: Arc::new(ProfileRuntime { + app_attest: Arc::new(app_attest), + transport: Arc::new(NeverTransport), + }), + delivery_url: "https://push.buzz.xyz/v1/deliveries/apns".parse().unwrap(), + max_grant_lifetime_seconds: 86_400, + max_installation_lifetime_seconds: 86_400, + endpoint_quota_window_seconds: 60, + endpoint_quota_max_deliveries: 10, + now: fixed_now, + accepting: Arc::new(AtomicBool::new(true)), + } + } + + fn maximum_enrollment_body() -> Vec { + serde_json::to_vec(&InstallationEnrollRequest { + v: WIRE_VERSION, + challenge_id: uuid::Uuid::nil(), + challenge: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0; 32]), + key_id: STANDARD.encode([0; 32]), + attestation: STANDARD.encode(vec![0; MAX_APP_ATTESTATION_BYTES]), + app_profile: AppProfile::BuzzIosDogfood, + endpoint: "ab".repeat(MAX_ENDPOINT_HEX_BYTES), + endpoint_epoch: 1, + expires_at: fixed_now() + 60, + }) + .unwrap() + } + + #[tokio::test] + async fn maximum_valid_enrollment_envelope_reaches_the_handler() { + let body = maximum_enrollment_body(); + assert!(body.len() > MAX_REQUEST_BYTES); + assert!(body.len() <= MAX_ENROLL_REQUEST_BYTES); + let (public, _) = router(state()); + let response = public + .oneshot( + Request::post("/v1/installations") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn enrollment_envelope_stays_bounded() { + let (public, _) = router(state()); + let response = public + .oneshot( + Request::post("/v1/installations") + .header("content-type", "application/json") + .body(Body::from(vec![b' '; MAX_ENROLL_REQUEST_BYTES + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } +} + +/// 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/main.rs b/crates/buzz-push-gateway/src/main.rs index 55e1853d3bf..db35b251104 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -35,12 +35,23 @@ 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 configured = &c.profile; + let profile = { + let transport = Arc::new(ApnsTransport::certificate( + &fs::read(&configured.apns_cert_path)?, + configured.apns_topic.clone(), + configured.apns_environment, + )?); + let apple = AppAttestVerifier::new( + configured.app_attest_app_id.clone(), + app_attest_root.clone(), + )?; + buzz_push_gateway::http::ProfileRuntime { + app_attest: Arc::new(apple), + transport, + } + }; let grant_keyring = GrantKeyring::new( c.grant_keys .iter() @@ -77,24 +88,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, + profile: Arc::new(profile), delivery_url: c.public_delivery_url, max_grant_lifetime_seconds: c.max_grant_lifetime_seconds, max_installation_lifetime_seconds: c.max_installation_lifetime_seconds, 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..390f665d8ab 100644 --- a/crates/buzz-push-gateway/src/model.rs +++ b/crates/buzz-push-gateway/src/model.rs @@ -3,6 +3,13 @@ use serde::{Deserialize, Serialize}; pub const MAX_REQUEST_BYTES: usize = 8 * 1024; +/// Maximum decoded Apple App Attest object accepted by the verifier. +pub const MAX_APP_ATTESTATION_BYTES: usize = 16 * 1024; +/// Enrollment carries the maximum App Attest object as standard base64 plus a +/// bounded APNs endpoint and the closed JSON envelope. Other gateway requests +/// remain subject to `MAX_REQUEST_BYTES`. +pub const MAX_ENROLL_REQUEST_BYTES: usize = + MAX_APP_ATTESTATION_BYTES.div_ceil(3) * 4 + MAX_ENDPOINT_HEX_BYTES * 2 + 1024; pub const MAX_GRANT_BYTES: usize = 4096; pub const MAX_ENDPOINT_HEX_BYTES: usize = 512; pub const APNS_RECONNECT_PAYLOAD: &[u8] = @@ -12,14 +19,12 @@ 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, } 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", } } } diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index bd69ec25646..19ba7daef0f 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -63,8 +63,7 @@ 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), _ => Err(AuthorityError::Unavailable), } } @@ -119,16 +118,35 @@ impl AuthorityStore for PostgresAuthorityStore { async fn put_challenge(&self, c: Challenge) -> Result<(), AuthorityError> { use sha2::{Digest, Sha256}; + const CHALLENGE_ISSUANCE_LOCK: i64 = 0x4255_5a5a_504c_0001; + let mut tx = self.pool.begin().await.map_err(db)?; + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(CHALLENGE_ISSUANCE_LOCK) + .execute(&mut *tx) + .await + .map_err(db)?; + let window_start = at(c.created_at.saturating_sub(CHALLENGE_QUOTA_WINDOW_SECONDS))?; + let issued: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_gateway_challenges WHERE created_at >= $1", + ) + .bind(window_start) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + if issued >= CHALLENGE_QUOTA_MAX_REQUESTS as i64 { + return Err(AuthorityError::RateLimited); + } sqlx::query( - "INSERT INTO push_gateway_challenges(id,challenge_hash,expires_at) VALUES($1,$2,$3)", + "INSERT INTO push_gateway_challenges(id,challenge_hash,expires_at,created_at) VALUES($1,$2,$3,$4)", ) .bind(c.id) .bind(Sha256::digest(c.value).to_vec()) .bind(at(c.expires_at)?) - .execute(&self.pool) + .bind(at(c.created_at)?) + .execute(&mut *tx) .await .map_err(db)?; - Ok(()) + tx.commit().await.map_err(db) } async fn consume_challenge( &self, @@ -144,12 +162,55 @@ impl AuthorityStore for PostgresAuthorityStore { } Ok(()) } - async fn create_installation(&self, n: NewInstallation) -> Result<(), AuthorityError> { + async fn create_installation( + &self, + n: NewInstallation, + now: i64, + ) -> Result<(), AuthorityError> { + let mut tx = self.pool.begin().await.map_err(db)?; + let now_at = at(now)?; + let existing = sqlx::query( + "SELECT id,expires_at,revoked_at FROM push_gateway_installations WHERE app_attest_key_id=$1 OR (app_profile=$2 AND token_fingerprint=$3) FOR UPDATE", + ) + .bind(&n.app_attest_key_id) + .bind(n.profile.as_str()) + .bind(n.token_fingerprint.to_vec()) + .fetch_all(&mut *tx) + .await + .map_err(db)?; + if existing.iter().any(|row| { + let revoked = row.try_get::>, _>("revoked_at"); + let expires = row.try_get::, _>("expires_at"); + match (revoked, expires) { + (Ok(None), Ok(expires_at)) => expires_at >= now_at, + (Ok(Some(_)), Ok(_)) => false, + _ => true, + } + }) { + return Err(AuthorityError::Rejected); + } + let replaced = existing + .iter() + .map(|row| row.try_get::("id").map_err(db)) + .collect::, _>>()?; + if !replaced.is_empty() { + sqlx::query("DELETE FROM push_gateway_delegations WHERE installation_id = ANY($1)") + .bind(&replaced) + .execute(&mut *tx) + .await + .map_err(db)?; + sqlx::query("DELETE FROM push_gateway_installations WHERE id = ANY($1)") + .bind(&replaced) + .execute(&mut *tx) + .await + .map_err(db)?; + } let result = sqlx::query("INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING") - .bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.profile.as_str()).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&self.pool).await.map_err(db)?; + .bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.profile.as_str()).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&mut *tx).await.map_err(db)?; if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); } + tx.commit().await.map_err(db)?; Ok(()) } async fn installation(&self, id: Uuid, now: i64) -> Result { @@ -192,7 +253,6 @@ impl AuthorityStore for PostgresAuthorityStore { .map_err(db)? .is_some() || i.try_get::("endpoint_epoch").map_err(db)? != d.endpoint_epoch - || at(d.expires_at)? > i.try_get::, _>("expires_at").map_err(db)? { return Err(AuthorityError::Rejected); } @@ -202,6 +262,12 @@ impl AuthorityStore for PostgresAuthorityStore { if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); } + sqlx::query("UPDATE push_gateway_installations SET expires_at=GREATEST(expires_at,$2),updated_at=now() WHERE id=$1") + .bind(d.installation_id) + .bind(at(d.expires_at)?) + .execute(&mut *tx) + .await + .map_err(db)?; tx.commit().await.map_err(db)?; Ok(()) } @@ -226,10 +292,10 @@ impl AuthorityStore for PostgresAuthorityStore { &self, id: Uuid, relay: &str, - generation: i64, + expected_generation: i64, ) -> Result<(), AuthorityError> { let relay = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?; - let result=sqlx::query("UPDATE push_gateway_delegations SET generation=$3,revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation<$3").bind(id).bind(relay).bind(generation).execute(&self.pool).await.map_err(db)?; + let result=sqlx::query("UPDATE push_gateway_delegations SET revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation=$3 AND revoked_at IS NULL").bind(id).bind(relay).bind(expected_generation).execute(&self.pool).await.map_err(db)?; if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); } @@ -409,7 +475,7 @@ mod tests { use super::*; use sqlx::{postgres::PgPoolOptions, AssertSqlSafe}; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials #[tokio::test] #[ignore = "requires PostgreSQL with CREATEDB/CREATEROLE"] @@ -624,7 +690,13 @@ mod tests { // Real DDL from migration 0010 (minus the _operator_global_tables audit // insert, which lives outside the isolated schema). sqlx::raw_sql( - "CREATE TABLE push_gateway_installations ( + "CREATE TABLE push_gateway_challenges ( + id UUID PRIMARY KEY, + challenge_hash BYTEA NOT NULL CHECK (length(challenge_hash) = 32), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL + ); + CREATE TABLE push_gateway_installations ( id UUID PRIMARY KEY, app_attest_key_id BYTEA NOT NULL UNIQUE, app_attest_public_key BYTEA NOT NULL, @@ -678,12 +750,59 @@ mod tests { const RELAY_HEX: &str = "11111111111111111111111111111111111111111111111111111111111111aa"; const DELEGATION_ID: u128 = 2; + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn concurrent_challenge_issuance_obeys_deployment_global_ceiling() { + let (pool, schema) = full_schema(4).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let now = Utc::now().timestamp(); + for offset in 0..CHALLENGE_QUOTA_MAX_REQUESTS - 1 { + sqlx::query( + "INSERT INTO push_gateway_challenges(id,challenge_hash,expires_at,created_at) VALUES($1,$2,$3,$4)", + ) + .bind(Uuid::from_u128(offset as u128 + 1)) + .bind(vec![offset as u8; 32]) + .bind(at(now + 300).expect("valid expiry")) + .bind(at(now).expect("valid creation time")) + .execute(&pool) + .await + .expect("seed challenge quota"); + } + let challenge = |id| Challenge { + id, + value: [0; 32], + created_at: now, + expires_at: now + 300, + }; + let (first, second) = tokio::join!( + store.put_challenge(challenge(Uuid::new_v4())), + store.put_challenge(challenge(Uuid::new_v4())), + ); + assert_eq!( + [first.is_ok(), second.is_ok()] + .into_iter() + .filter(|admitted| *admitted) + .count(), + 1, + "the cross-connection lock admits only the final quota slot" + ); + assert!( + [first, second] + .into_iter() + .any(|result| result == Err(AuthorityError::RateLimited)), + "the quota loser receives an explicit rate-limit result" + ); + + pool.close().await; + drop_schema(&schema).await; + } + // One installation + one live delegation that admits at now=1_000. async fn install_authority(pool: &PgPool) { let now = Utc::now(); sqlx::query( "INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) - VALUES ($1,$2,$3,0,'buzz-ios-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]) @@ -708,6 +827,72 @@ mod tests { .expect("insert delegation"); } + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn delegation_renews_and_expired_enrollment_recovers_token_ownership() { + let (pool, schema) = full_schema(2).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let now = Utc::now().timestamp(); + let installation = |id, expires_at| NewInstallation { + id, + app_attest_key_id: vec![1], + app_attest_public_key: vec![2; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![3], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at, + }; + + store + .create_installation(installation(Uuid::from_u128(1), now + 100), now) + .await + .expect("create initial installation"); + store + .upsert_delegation(Delegation { + id: Uuid::from_u128(2), + installation_id: Uuid::from_u128(1), + relay_pubkey: RELAY_HEX.to_owned(), + endpoint_epoch: 1, + generation: 1, + not_before: now, + expires_at: now + 1_000, + revoked: false, + }) + .await + .expect("authenticated delegation renews installation"); + assert!(store + .installation(Uuid::from_u128(1), now + 500) + .await + .is_ok()); + assert_eq!( + store + .create_installation(installation(Uuid::from_u128(3), now + 2_000), now + 999,) + .await, + Err(AuthorityError::Rejected) + ); + store + .create_installation(installation(Uuid::from_u128(3), now + 2_000), now + 1_001) + .await + .expect("expired ownership can be replaced"); + let old_delegations: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_gateway_delegations WHERE installation_id=$1", + ) + .bind(Uuid::from_u128(1)) + .fetch_one(&pool) + .await + .expect("count replaced delegations"); + assert_eq!(old_delegations, 0); + assert!(store + .installation(Uuid::from_u128(3), now + 1_001) + .await + .is_ok()); + + pool.close().await; + drop_schema(&schema).await; + } + fn admit<'a>( store: &'a PostgresAuthorityStore, event_hex: &'a str, 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-good.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json new file mode 100644 index 00000000000..3bdff5ccdce --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json @@ -0,0 +1,9 @@ +{ + "description": "Valid synthetic Apple App Attest attestation for the gateway strict-verifier acceptance control.", + "app_id": "TEAMID.xyz.buzz.mobile", + "challenge": "buzz-app-attest-strict-verifier-fixture", + "aaguid": "appattest", + "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..56d1260bead --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json @@ -0,0 +1,9 @@ +{ + "description": "Synthetic attestation with a development AAGUID and a correctly recomputed nonce; the strict verifier must report InvalidAAGUID.", + "app_id": "TEAMID.xyz.buzz.mobile", + "challenge": "buzz-app-attest-strict-verifier-fixture", + "aaguid": "appattestdevelop", + "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..7129b63939e --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json @@ -0,0 +1,9 @@ +{ + "description": "Internally valid synthetic attestation signed by an unrelated root; the verifier configured with the good fixture root must reject it.", + "app_id": "TEAMID.xyz.buzz.mobile", + "challenge": "buzz-app-attest-strict-verifier-fixture", + "aaguid": "appattest", + "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 9fb96b7a198..f43d459b9ec 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -297,10 +297,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, @@ -897,6 +901,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 { @@ -905,6 +910,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( @@ -1073,6 +1084,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, @@ -1630,11 +1642,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 @@ -1644,9 +1670,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 { @@ -1654,6 +1693,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..bf5783d0730 100644 --- a/crates/buzz-relay/src/handlers/push_lease.rs +++ b/crates/buzz-relay/src/handlers/push_lease.rs @@ -12,8 +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]; -pub(crate) const URGENT_KINDS: &[u64] = &[]; +/// 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]; /// NIP-PL addressable push-lease event kind. pub const KIND_PUSH_LEASE: u32 = 30_350; @@ -68,7 +70,6 @@ pub struct LeaseLimits<'a> { pub app_profiles: &'a [AppProfile<'a>], pub supported_classes: &'a [&'a str], pub push_kinds: &'a [u64], - pub urgent_kinds: &'a [u64], pub max_subscriptions: usize, pub max_kinds: usize, pub max_authors: usize, @@ -245,14 +246,13 @@ fn validate_subscription(sub: &Subscription, limits: &LeaseLimits<'_>) -> Result if !limits.supported_classes.contains(&sub.class.as_str()) { return Err("class not supported".into()); } - validate_filter(&sub.filter, limits, true, &sub.class)?; + validate_filter(&sub.filter, limits, true)?; if sub.ignore.len() > limits.max_ignore { return Err("ignore quota exceeded".into()); } for filter in &sub.ignore { - // Ignore filters can only subtract from an already-positive match, so - // urgent-kind confinement belongs solely to the positive filter. - validate_filter(filter, limits, false, "")?; + // Ignore filters can only subtract from an already-positive match. + validate_filter(filter, limits, false)?; } if sub.suppress.as_ref().is_some_and(|s| s.p_tags_max == 0) { return Err("p_tags_max must be positive".into()); @@ -264,7 +264,6 @@ fn validate_filter( filter: &Map, limits: &LeaseLimits<'_>, require_narrowing: bool, - class: &str, ) -> Result<(), String> { const ALLOWED: &[&str] = &["kinds", "authors", "#p", "#h", "#e"]; if let Some(key) = filter.keys().find(|key| !ALLOWED.contains(&key.as_str())) { @@ -282,10 +281,6 @@ fn validate_filter( if kinds.iter().any(|kind| !limits.push_kinds.contains(kind)) { return Err("kind not push-eligible".into()); } - if class == "urgent" && kinds.iter().any(|kind| !limits.urgent_kinds.contains(kind)) { - return Err("class not permitted for kind".into()); - } - let authors = optional_string_array(filter, "authors", limits.max_authors)?; let p = optional_string_array(filter, "#p", limits.max_tag_values)?; let h = optional_string_array(filter, "#h", limits.max_h)?; @@ -477,7 +472,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)?; @@ -496,19 +491,12 @@ pub async fn accept( let limits = LeaseLimits { expected_origin: &origin, author_hex: &author_hex, - app_profiles: &[ - AppProfile { - id: "buzz-ios-production", - transport: "apns", - }, - AppProfile { - id: "buzz-ios-sandbox", - transport: "apns", - }, - ], - supported_classes: &["silent", "default", "time_sensitive"], + app_profiles: &[AppProfile { + id: "buzz-ios-dogfood", + transport: "apns", + }], + supported_classes: &["default"], push_kinds: PUSH_KINDS, - urgent_kinds: URGENT_KINDS, max_subscriptions: 16, max_kinds: 16, max_authors: 20, @@ -531,25 +519,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, @@ -572,14 +564,8 @@ pub async fn accept( .map_err(|_| AcceptError::Internal("lease persistence failed".to_string())) } -fn class_rank(class: &str) -> u8 { - match class { - "silent" => 0, - "default" => 1, - "time_sensitive" => 2, - "urgent" => 3, - _ => 0, - } +fn class_rank(_: &str) -> u8 { + 1 } fn canonical_origin(relay_url: &str, host: &str) -> Result { @@ -680,9 +666,8 @@ mod tests { id: "p", transport: "apns", }], - supported_classes: &["default", "urgent"], - push_kinds: &[9, 46010], - urgent_kinds: &[46010], + supported_classes: &["default"], + push_kinds: &[9], max_subscriptions: 4, max_kinds: 4, max_authors: 4, @@ -702,7 +687,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}" @@ -759,13 +744,4 @@ mod tests { assert!(canonical_origin("https://relay.example", "tenant.example").is_err()); assert!(canonical_origin("wss://relay.example", "").is_err()); } - - #[test] - fn urgent_is_limited_by_event_kind() { - let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9],"#p":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},"class":"urgent"}]}"##, 4096).unwrap(); - assert_eq!( - validate_plaintext(&body, &limits()).unwrap_err(), - "class not permitted for kind" - ); - } } diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 3e39b22f8b2..06bdf673b39 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -161,6 +161,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" ); @@ -168,6 +169,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, @@ -727,15 +729,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 7bcebf1ef33..18028258631 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -238,14 +238,10 @@ fn push_descriptor( "pubkey": relay_keypair.public_key().to_hex(), "current": true }], - "app_profiles": [ - {"id": "buzz-ios-production", "transport": "apns"}, - {"id": "buzz-ios-sandbox", "transport": "apns"} - ], + "app_profiles": [{"id": "buzz-ios-dogfood", "transport": "apns"}], "push_kinds": crate::handlers::push_lease::PUSH_KINDS, - "urgent_kinds": crate::handlers::push_lease::URGENT_KINDS, "h_grammar": "uuid-v4-lowercase", - "class_support": {"apns": ["silent", "default", "time_sensitive"]}, + "class_support": {"apns": ["default"]}, "limitation": { "max_lease_ttl": 2592000, "max_leases_per_pubkey": 16, @@ -281,7 +277,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st state.config.pairing_relay_url.as_deref(), state.config.klipy.as_ref().map(|_| "klipy"), ); - 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() @@ -290,7 +286,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..246997aac22 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" } } @@ -597,14 +682,8 @@ fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result u8 { - match class { - "silent" => 0, - "default" => 1, - "time_sensitive" => 2, - "urgent" => 3, - _ => 0, - } +fn class_rank(_: &str) -> u8 { + 1 } #[cfg(test)] @@ -675,7 +754,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..20ce7567270 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -31,17 +31,18 @@ spec: - { name: BUZZ_PUSH_HEALTH_ADDR, value: "0.0.0.0:8081" } - { name: BUZZ_PUSH_PUBLIC_DELIVERY_URL, value: {{ .Values.publicDeliveryUrl | quote }} } - { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | quote }} } - - { name: BUZZ_PUSH_ENABLED_PROFILES, value: {{ .Values.enabledProfiles | quote }} } - - { name: BUZZ_PUSH_APP_ATTEST_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 } + {{- 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 } 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 +50,8 @@ 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.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..250955c5fc2 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,87 @@ 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_GRANT_KEYS BUZZ_PUSH_TOKEN_KEYS BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS +]) +assert!(required.subset?(env_names)) +assert!(!env_names.any? { |name| name.include?("APP_STORE") }) +apns_volume = d.dig("spec", "template", "spec", "volumes").find { |volume| volume["name"] == "apns-dogfood" } +assert!(apns_volume.dig("secret", "defaultMode") == 0o400, apns_volume.inspect) +assert!(d.dig("spec", "replicas") >= 2) +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 +124,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..29eafa22c8d 100644 --- a/deploy/charts/buzz-push-gateway/values.schema.json +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -19,10 +19,19 @@ "minimum": 1, "maximum": 31536000 }, - "appAttestAppId": { - "type": "string", - "minLength": 1 + "profiles": { + "type": "object", + "additionalProperties": false, + "required": [ + "dogfood" + ], + "properties": { + "dogfood": { + "$ref": "#/$defs/enabledProfile" + } + } }, + "apnsKey": false, "httpRoute": { "type": "object", "required": [ @@ -232,12 +241,72 @@ } } }, + "$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" + ] + } + ] + }, + "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..1f1e90cbb08 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -20,16 +20,18 @@ 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 +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 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..c6dc160e3c8 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -17,7 +17,7 @@ Push Leases ## Abstract -This NIP defines the **push lease**: a stored, installation-scoped, expiring authorization asking a **push executor** (usually the user's relay) to keep a constrained Nostr filter active after the client's socket closes, and to *wake* a specific application installation through a platform push transport (APNs, FCM, optionally UnifiedPush) when the filter matches. +This NIP defines the **push lease**: a stored, installation-scoped, expiring authorization asking a **push executor** (usually the user's relay) to keep a constrained Nostr filter active after the client's socket closes, and to *wake* a specific application installation through a platform push transport (APNs or FCM) when the filter matches. The push payload is a **wake signal** authored entirely by the configured transport service: a fixed reconnect instruction, never relay-supplied bytes, event ids, event content, URLs, ciphertext, or extensible custom data. On wake, the client reconnects and fetches authoritative events over normal `REQ`. Push delivery is lossy and best-effort — duplicates and omissions are both possible; the relay remains the single source of truth. Platform transports are execution profiles for the lease, not the protocol's content plane. @@ -51,8 +51,8 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d - **origin**: the canonical origin identifier the descriptor advertises for a relay/community; the tenant key (see Acceptance and Origin Binding). - **wake signal**: the fixed, transport-authored reconnect payload defined in Wake Delivery. It contains no relay-supplied application data. - **subscription**: one `{filter, class, ignore?, suppress?}` entry inside a lease. -- **priority class**: one of `silent`, `default`, `time_sensitive`, `urgent`. -- **transport profile**: the APNs/FCM/UnifiedPush-specific execution rules for a lease. +- **priority class**: `default` in this profile. +- **transport profile**: the APNs/FCM-specific execution rules for a lease. ## The Lease Event @@ -73,7 +73,7 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d } ``` -- `d` MUST be generated from at least 128 bits of randomness by the installation, and MUST be distinct per origin — cross-origin unlinkability is a guarantee of this NIP, not a nicety. It MUST NOT contain or be derived from a hardware identifier, advertising identifier, APNs token, FCM registration token, UnifiedPush endpoint, or other transport identifier. Reinstalling the application MUST create a new `d`; transport-token rotation within the same installation MUST retain `d` and replace the existing lease. +- `d` MUST be generated from at least 128 bits of randomness by the installation, and MUST be distinct per origin — cross-origin unlinkability is a guarantee of this NIP, not a nicety. It MUST NOT contain or be derived from a hardware identifier, advertising identifier, APNs token, FCM registration token, or other transport identifier. Reinstalling the application MUST create a new `d`; transport-token rotation within the same installation MUST retain `d` and replace the existing lease. - `expiration` (NIP-40) is REQUIRED and MUST satisfy `now − allowed_skew < expiration ≤ now + max_lease_ttl` at acceptance (`invalid: lease ttl too long` / `invalid: lease already expired`; `max_lease_ttl` descriptor-advertised, default 30 days; RECOMMENDED `allowed_skew` 15 minutes). The executor MUST stop matching once it passes. Inactive (tombstone) replacements carry a public `expiration` under the same bound; it dates the tombstone, not any matching. Expiry is the self-healing backstop for every abuse and leak below. - `exec` names the descriptor encryption key the content was produced for (see Executor Discovery). - Public tags are exactly one `d`, one `expiration`, one `exec`, and at most one `alt`, each with exactly one value; duplicated tags, extra tags, or extra tag values MUST be rejected. The executor MUST reject a lease carrying filter, kind, author, endpoint, or platform data in public tags. @@ -87,12 +87,12 @@ This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as d "v": 1, "origin": "", // tenant binding, verified — never routed on "app_profile": "com.example.app/ios", // selects transport credentials - "transport": "apns", // "apns" | "fcm" | "unifiedpush" - "endpoint": "", // APNs token / FCM token / UP URL + "transport": "apns", // "apns" | "fcm" + "endpoint": "", // APNs or FCM token/capability "generation": 3, // strictly increasing per lease address "active": true, // false = revocation tombstone "subscriptions": [ - { "filter": { "kinds": [9], "#p": [""] }, "class": "time_sensitive" }, + { "filter": { "kinds": [9], "#p": [""] }, "class": "default" }, { "filter": { "kinds": [9], "#h": [""] }, "class": "default", "ignore": [ { "kinds": [9], "authors": [""], "#h": [""] } ], "suppress": { "p_tags_max": 20 } } @@ -146,16 +146,9 @@ Each subscription carries exactly one `class`: | Class | Meaning | APNs `interruption-level` | Android importance | |---|---|---|---| -| `silent` | Sync-only wake, no alert | not user-visible; see APNs profile | `IMPORTANCE_MIN` | | `default` | Standard notification | `active` | `IMPORTANCE_DEFAULT` | -| `time_sensitive` | Breaks through Focus/DND within OS policy | `time-sensitive` | `IMPORTANCE_HIGH` | -| `urgent` | Reserved: approval gates | `critical` if entitled, else `time-sensitive` | `IMPORTANCE_HIGH` + full-screen intent where policy allows | -Classes are strictly ordered: `silent` < `default` < `time_sensitive` < `urgent`. When one deduplicated wake covers matches from multiple subscriptions or leases targeting the same endpoint (see Coalescing), the wake's effective class is the highest eligible class among those matches. The descriptor's `class_support` is authoritative: a lease naming a class unsupported for its transport MUST be rejected at acceptance (`invalid: class not supported`), never silently downgraded. - -The executor MUST restrict `urgent` to the descriptor-advertised allow-list of approval-request kinds whose eligibility is decidable from the public event envelope (`invalid: class not permitted for kind`). Urgent DMs are explicitly out of scope for v1: gift-wrapped DM content is opaque to the executor, so no privacy-safe urgency marker exists yet; a future revision may add one. - -`silent` remains a matching preference only. The public Buzz APNs profile sends the one fixed reconnect alert and does not expose relay-selected notification classes to the transport boundary. +The descriptor's `class_support` is authoritative: a lease naming an unsupported class MUST be rejected at acceptance (`invalid: class not supported`), never silently downgraded. The public Buzz APNs profile sends the one fixed reconnect alert and does not expose relay-selected notification classes to the transport boundary. Clients MUST NOT register any lease or subscription as a side effect of joining a channel or surface — absent explicit user opt-in the notifiable set is empty. @@ -176,10 +169,8 @@ Until this draft has an upstream NIP number, executors MUST NOT advertise it in "app_profiles": [ { "id": "com.example.app/ios", "transport": "apns" }, { "id": "com.example.app/android", "transport": "fcm" } ], "push_kinds": [9, 1059, 40007, 46010, 7], - "urgent_kinds": [46010], "h_grammar": "uuid-v4-lowercase", - "class_support": { "apns": ["silent","default","time_sensitive","urgent"], - "fcm": ["silent","default","time_sensitive","urgent"] }, + "class_support": { "apns": ["default"], "fcm": ["default"] }, "limitation": { "max_lease_ttl": 2592000, "max_leases_per_pubkey": 16, @@ -192,9 +183,9 @@ Until this draft has an upstream NIP number, executors MUST NOT advertise it in } ``` -A descriptor is valid only if: exactly one key is marked `current` and key ids are unique; app-profile ids are unique; `endpoint` is an `https://` URL; `urgent_kinds ⊆ push_kinds`; and every `class_support` value comes from the class registry in this NIP. Clients MUST treat a descriptor failing these checks as absence of push support. +A descriptor is valid only if: exactly one key is marked `current` and key ids are unique; app-profile ids are unique; `endpoint` is an `https://` URL; and every `class_support` value comes from the class registry in this NIP. Clients MUST treat a descriptor failing these checks as absence of push support. -The executor URL and credentials come from the descriptor, never from the lease. A lease cannot point the executor at an arbitrary HTTP endpoint; this removes the callback-amplification class of attack entirely. Executors MUST NOT dereference a client-supplied `endpoint` URL except as the selected transport profile explicitly defines (UnifiedPush is the only profile whose endpoint is a URL, and it is validated per that profile before use). +The executor URL and credentials come from the descriptor, never from the lease. A lease cannot point the executor at an arbitrary HTTP endpoint; this removes the callback-amplification class of attack entirely. Leases MUST be author-only reads, as specified in Acceptance and Origin Binding, following the NIP-ER access pattern. @@ -230,10 +221,6 @@ The APNs application body is the exact UTF-8 byte constant `{"aps":{"alert":{"bo A future FCM profile MUST define one gateway-owned constant data message with identical noninterference semantics. Until that constant and its wire tests are registered, FCM is not a conforming v1 public-gateway profile. -### UnifiedPush (optional) - -UnifiedPush is not a conforming public-gateway profile in v1 because arbitrary distributor endpoints and message bodies do not meet the fixed-payload authority boundary. A future profile requires a separately registered constant body and hostile-endpoint analysis. - ## Lease and Key Lifecycle A lease is identified by `(author, kind, d)`. A replacement supersedes the prior lease at the same address only by passing the full acceptance sequence, including winning both NIP-01 addressable ordering and the strictly-increasing generation watermark (check 8). Any rejected replacement — stale by either ordering, or invalid for any other reason — MUST leave the stored event, effective push state, and watermark unchanged. @@ -264,7 +251,14 @@ 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` value is `buzz-ios-dogfood`. It identifies the +closed Buzz dogfood application identity, not an APNs transport environment. +The canonical gateway owns its exact App Attest application identifier, APNs +topic, certificate-backed connection pool, and APNs environment. Enrollment +succeeds only when App Attest cryptographically verifies the configured +application identifier. The gateway MUST NOT accept an APNs topic from a client. The APNs token +registered with the gateway is called the **installation endpoint** and never +leaves gateway custody after enrollment. 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. @@ -296,7 +290,7 @@ Success `200`: {"challenge_id":"","challenge":"","expires_at":} ``` -The challenge is single-use. Invalid input is `400 invalid_request`; storage/randomness failure is `503 temporarily_unavailable`. +The challenge is single-use. Invalid input is `400 invalid_request`; deployment-global challenge issuance limits return `429 rate_limited`; storage/randomness failure is `503 temporarily_unavailable`. ### Installation enrollment @@ -305,7 +299,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: @@ -320,7 +314,7 @@ The gateway verifies Apple's attestation chain, configured application identifie {"installation_handle":"","endpoint_epoch":1,"expires_at":} ``` -Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge or duplicate key/token is `404 not_authorized`. +Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge or a key/token owned by a live installation is `404 not_authorized`. A fresh verified enrollment may replace expired or revoked ownership so an app that missed its renewal window can recover. ### Relay delegation and capability issuance @@ -330,7 +324,7 @@ Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge o {"v":1,"challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"generation":,"relay_pubkey":"<64-lowercase-hex>","not_before":,"expires_at":,"assertion":""} ``` -`not_before <= now + 300`, `not_before < expires_at`, and `expires_at <= min(now + configured_max_grant_lifetime, installation.expires_at)`. The endpoint epoch MUST equal the current installation epoch. For each `(installation_handle, relay_pubkey)`, generation MUST strictly increase. Transcript domain `buzz.push.delegate.v1`; ordered object: +`not_before <= now + 300`, `not_before < expires_at`, and `expires_at <= now + configured_max_grant_lifetime`. The endpoint epoch MUST equal the current installation epoch. For each `(installation_handle, relay_pubkey)`, generation MUST strictly increase. A successful delegation atomically extends the authenticated installation lifetime through at least the delegation's `expires_at`, allowing renewal without duplicate token enrollment. Transcript domain `buzz.push.delegate.v1`; ordered object: ```json {"v":1,"audience":"https://push.buzz.xyz/v1/delegations","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"generation":,"relay_pubkey":"","not_before":,"expires_at":} @@ -368,7 +362,7 @@ Transcript domain `buzz.push.revoke-delegation.v1`; ordered object: {"v":1,"audience":"https://push.buzz.xyz/v1/delegations/revoke","challenge_id":"","challenge":"","installation_handle":"","relay_pubkey":"","generation":} ``` -The generation identifies the current delegation generation. Success is `200 {"status":"revoked"}`. +The supplied generation MUST equal the current delegation generation. The gateway atomically compares it against the locked delegation row, marks that delegation revoked, and retains the same generation as its watermark. A stale or future generation is rejected without changing the delegation. Any later delegation for the same `(installation_handle, relay_pubkey)` MUST use a strictly greater generation. Success is `200 {"status":"revoked"}`. `POST /v1/installations/revoke` request: @@ -407,7 +401,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) @@ -437,6 +433,6 @@ Zombie leases (e.g. `#h` after leaving a channel) are neutralized by match-time - `kind:30350`: push lease (addressable) - `exec` tag: executor encryption-key identifier for `kind:30350` - NIP-11 `supported_extensions`: contains `"nip-pl"` pre-numbering; descriptor object `push` as specified in Executor Discovery -- Classes: `silent`, `default`, `time_sensitive`, `urgent` +- Classes: `default` - `h_grammar` values: `"uuid-v4-lowercase"` (initial entry; origins may register additional grammars with this NIP) -- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profiles `buzz-ios-production`, `buzz-ios-sandbox`; wire version `1` +- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profile `buzz-ios-dogfood`; wire version `1` diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 63c63355a11..e9a9ae16055 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -16,21 +16,38 @@ | `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_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_ATTEST_APP_ID` | Exact server-owned Apple App Attest application identifier (`TEAMID.bundle-id`). | +| `BUZZ_PUSH_DOGFOOD_APNS_TOPIC` | Server-owned APNs topic. Never accepted from a client. | +| `BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT` | `production` or `sandbox`, selected by deployment configuration. | +| `BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH` | Read-only certificate/private-key PEM. | | `BUZZ_PUSH_GRANT_KEYS` | Capability AEAD keyring, `id:base64-32-bytes[,predecessor...]`; current key first. | | `BUZZ_PUSH_TOKEN_KEYS` | Independent token-custody AEAD keyring in the same format. Never reuse grant keys. | +The canonical `push.buzz.xyz` MVP serves the dogfood application identity +(`xyz.block.buzz.dogfood.mobile`). App Attest must cryptographically validate +the configured application ID before enrollment. Assertions and delivery use +the server-owned APNs topic, certificate-backed connection pool, and +environment. No client request or relay grant can supply or override an APNs +topic. + +This MVP has exactly one compiled-in application profile, +`buzz-ios-dogfood`. The chart value +`profiles.dogfood.appAttestAppId` is rendered as +`BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID`; the gateway rejects startup when it is +missing or empty. The exact `TEAMID.bundle-id` is environment-owned, +non-secret deployment configuration. The chart's production values file leaves +it empty deliberately so a production renderer must supply it from the GitOps +environment rather than baking a Block team identifier into this repository. +Supporting another application identity requires an explicit code, schema, +chart, credential, and deployment change; this gateway does not currently +select among multiple application profiles. + 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 +61,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 +80,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 APNs certificate/topic/environment is unhealthy. Check `BUZZ_PUSH_DOGFOOD_APNS_*` configuration. No endpoints are being invalidated. | | `PushGatewayAdmissionUnavailable` | any admission `unavailable` for 5m | critical | PostgreSQL authority store is unreachable. Check DB connectivity and the pod's `postgresEgressCidrs` NetworkPolicy. | | `PushGatewayReadinessAuthorityFailing` | readiness `authority` failures for 5m | warning | Replicas are being pulled from the Service on DB check failure. Fix DB health before capacity drops below the PodDisruptionBudget. | | `PushGatewayReaperFailing` | reaper failed ≥2 times within 30m (runs every 5m) | warning | Expired reservations aren't being swept, growing the bounded-until-expiry window. Check DB write availability. | @@ -72,23 +88,98 @@ 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 the canonical gateway's sole dogfood +profile is configured with its server-owned App Attest app ID, APNs topic, +production certificate identity, and production APNs environment, and only the +selected internal relay deployments set `BUZZ_PUSH_ENABLED=true`. Every iOS +artifact contains the native push bridge and Notification Service Extension, +but the client remains inactive until its current authenticated relay +advertises a fully valid NIP-11 `nip-pl` descriptor. There is no App Store +gateway profile in this MVP. + +Physical-device validation must use an application whose App Attest identity +and APNs topic match the configured dogfood profile. The current gateway cannot +enroll `xyz.block.buzz.mobile` or another bundle identifier merely by changing +deployment values: adding another identity requires the explicit multi-profile +work described above. + +Dogfood end-to-end release validation starts after this feature reaches `main`: +publish the next immutable `mobile-vX.Y.Z-rc.N` candidate from the exact current +`origin/main` commit, build that tag through the normal Block release pipeline, +and wait for the signed `xyz.block.buzz.dogfood.mobile` artifact to appear in +Mobile Releases/Comp Portal before installing it on a physical device. Verify +APNs delivery, fetched and signature-verified notification content, and +exact-message tap routing against the canonical gateway and a push-enabled +internal relay before widening the internal evaluation. + +Before that first candidate, the private dogfood builder's manual signing and +export configuration must map separate distribution +profiles for both `xyz.block.buzz.dogfood.mobile` and +`xyz.block.buzz.dogfood.mobile.NotificationService`; an app-only profile does +not provision the extension. App Store rollout remains off through relay and +gateway deployment configuration until separately approved. +Before enabling rich message presentation, enable Apple's Communication +Notifications capability on the parent dogfood App ID and regenerate its app +provisioning profile. The extension profile does not need that capability. +Apply the same parent-App-ID prerequisite to the eventual App Store rollout; +updating Block Apple portal records is a separately authorized release step. + +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. If the gateway itself +is unhealthy, disable the gateway deployment only after relay delivery is off. +Existing leases and gateway authorities then expire naturally. Adding an App +Store application profile is outside 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 +190,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 bef08e52098..c108dcece25 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -31,7 +31,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 @@ -92,6 +93,56 @@ 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. +### iOS push capability + +Every iOS artifact builds and embeds the Notification Service Extension and +native push bridge. Runtime activation is fail-closed and scoped to the current +relay. After authenticated connectivity and a fully valid NIP-11 `nip-pl` push +descriptor, Buzz independently requests display permission and registers with +APNs. Display denial or request failure does not gate the device token, gateway +enrollment, or lease publication, so a later user opt-in can display pushes +without rebuilding transport authority. An absent, malformed, or unreachable +descriptor leaves push inactive without partial enrollment. + +Relay rollout remains an explicit deployment opt-in. Only deployments with +`BUZZ_PUSH_ENABLED=true` advertise the descriptor and process push. See +`docs/push-gateway-deployment.md` for the canonical gateway profile contract, +manual physical-device proof, measurements, and rollback procedure. + +For local physical-device development, override the identity and sandbox +environments in the gitignored `mobile/ios/Flutter/AppOverrides.xcconfig`: + +```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. + +Parent app identifiers require Apple's Communication +Notifications capability and a regenerated app provisioning profile. The +Notification Service Extension profile does not require that capability. +Enable it on the personal development App ID for local rich-presentation +validation. Enabling it on the Block dogfood and eventual App Store App IDs is +a release follow-up and is not performed by this repository change. Without a +matching parent profile, source and unit validation still work, but the app +cannot be signed for a physical device. + +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..60f4d9e5110 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift @@ -0,0 +1,154 @@ +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 senderAvatar = descriptor.senderAvatarPNG.map(INImage.init(imageData:)) + let sender = INPerson( + personHandle: INPersonHandle(value: descriptor.senderIdentifier, type: .unknown), + nameComponents: nil, + displayName: descriptor.senderDisplayName, + image: senderAvatar, + 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 + if let senderAvatar { + // Communication Notifications render a group conversation's image + // from the speakable-group parameter rather than INPerson.image. + // Buzz channels do not have a separate avatar, so use the verified + // sender thumbnail for the visible incoming-message avatar. + intent.setImage(senderAvatar, forParameterNamed: \.speakableGroupName) + } + } + 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..c297153801a --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -0,0 +1,869 @@ +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) + } +} + +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) } + ) + } + + 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. An installation in + // its final five minutes is renewed by the authenticated delegation. + let reusableInstallation = storedRecords.first { record in + guard record.appProfile == Self.appProfile, + record.endpointHash == endpointHash, + record.endpointEpoch == Self.endpointEpoch, + record.expiresAt > nowSeconds, + let handle = record.gatewayInstallationHandle, + let uuid = UUID(uuidString: handle) + else { return false } + return handle == uuid.uuidString.lowercased() + } + + let (renewedExpiration, expiresOverflow) = nowSeconds.addingReportingOverflow(lifetimeSeconds) + guard !expiresOverflow else { + throw BuzzDevPushEnrollmentError.invalidGatewayURL + } + + let installation: UUID + let expiresAt: Int64 + if let reusableInstallation, + let handle = reusableInstallation.gatewayInstallationHandle, + let existing = UUID(uuidString: handle) + { + installation = existing + expiresAt = reusableInstallation.expiresAt > nowSeconds + 300 + ? reusableInstallation.expiresAt + : renewedExpiration + } else { + expiresAt = renewedExpiration + 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..47661e81300 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -0,0 +1,647 @@ +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 { + // Verified profiles may carry bounded inline raster avatars. Keep the refresh + // response capped, but large enough to recover the sender name from those + // otherwise valid kind-0 events when the app cache has not been populated. + static let maximumPresentationResponseBytes = 256 * 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 + && !$0.policies.isEmpty + } + 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 + !community.policies.isEmpty, + let relayURL = community.relayURL, + let url = URL(string: "/query", relativeTo: relayURL), + let body = try? JSONSerialization.data( + withJSONObject: community.policies.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() + && community.policies.contains { policy in + PushLeaseMatcher.matches(event: event, policy: policy) + } + }, + 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 + // A verified kind-0 profile may include a bounded inline raster avatar. + // Railway can take longer than one second to return that larger response, + // while three seconds remains a small fraction of the NSE execution budget. + request.timeoutInterval = 3 + 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.hasPrefix("#") ? displayName : "#\(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..f4a27e8a9f0 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -0,0 +1,788 @@ +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 communities: [PushLeaseCommunity] + public var profiles: [BuzzPushCachedProfile] + public var channels: [BuzzPushCachedChannel] + + public init( + version: Int = currentVersion, + communities: [PushLeaseCommunity] = [], + profiles: [BuzzPushCachedProfile] = [], + channels: [BuzzPushCachedChannel] = [] + ) { + self.version = version + self.communities = communities + self.profiles = profiles + self.channels = channels + } + + 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-snapshot.json" + public static let freshnessLifetime: TimeInterval = 24 * 60 * 60 + public static let maximumProfiles = 256 + public static let maximumChannels = 512 + public static let maximumCommunities = 64 + public static let maximumMembersPerChannel = 512 + public static let maximumTotalMemberDigests = 8_192 + public static let maximumAvatarBytes = 64 * 1024 + public static let maximumTotalAvatarBytes = 4 * 1024 * 1024 + public static let maximumSnapshotBytes = 8 * 1024 * 1024 + static let maximumProfileMetadataBytes = 256 * 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 + } + + /// Replaces the app's flattened, relay-accepted community query policy. + public func replaceCommunities(_ communities: [PushLeaseCommunity]) throws { + guard communities.count <= Self.maximumCommunities else { return } + lock.lock() + defer { lock.unlock() } + var snapshot = loadLocked() + snapshot.communities = communities + let retained = Set(communities.map(\.id)) + snapshot.profiles.removeAll { !retained.contains($0.communityID) } + snapshot.channels.removeAll { !retained.contains($0.communityID) } + try writeLocked(snapshot) + } + + /// Saves verified kind-0 events and returns the event IDs still needing thumbnails. + @discardableResult + public func updateProfiles( + 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 + } + + 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 <= maximumProfileMetadataBytes, + 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 else { return nil } + if trimmed.hasPrefix("data:image/") { + guard trimmed.utf8.count <= maximumProfileMetadataBytes, + let separator = trimmed.firstIndex(of: ","), + separator < trimmed.index(before: trimmed.endIndex) + else { return nil } + let metadata = trimmed[.. 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.communities = Array(snapshot.communities.prefix(maximumCommunities)) + 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..cb7eb791082 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift @@ -0,0 +1,143 @@ +import Foundation + +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 policies: [PushResolutionPolicy] + + public init( + id: String, + name: String, + relayUrl: String, + relayMetadataPubkey: String? = nil, + pubkey: String?, + policies: [PushResolutionPolicy] + ) { + self.id = id + self.name = name + self.relayUrl = relayUrl + self.relayMetadataPubkey = relayMetadataPubkey + self.pubkey = pubkey + self.policies = policies + } +} + +public struct PushResolutionPolicy: Codable, Equatable, Sendable { + public let filter: PushLeaseFilter + public let ignore: [PushLeaseFilter] + public let suppress: PushLeaseSuppression? + + public init( + filter: PushLeaseFilter, + ignore: [PushLeaseFilter] = [], + suppress: PushLeaseSuppression? = nil + ) { + self.filter = filter + 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, + policy: PushResolutionPolicy + ) -> Bool { + guard policy.filter.matches(event) else { return false } + if policy.ignore.contains(where: { $0.matches(event) }) { return false } + if let maximum = policy.suppress?.pTagsMax, + event.tagCount(named: "p") > maximum + { + return false + } + 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..0b42b48d4ba --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -0,0 +1,1123 @@ +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") + } + + 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 testExpiringGrantRenewsExistingInstallationAndReusesRelayLeaseAddress() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + 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) + } + ) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.firstChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + XCTFail("An expiring installation must renew through authenticated delegation") + return Self.response(request, status: 500, json: [:]) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["generation"] as? Int, 8) + XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) + 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.gatewayInstallationHandle, Self.installationHandle) + XCTAssertEqual(record.generation, 8) + XCTAssertEqual(record.expiresAt, Self.expiresAt) + 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..c79147dbea5 --- /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, 3) + 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..b25e23595f6 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -0,0 +1,852 @@ +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, 3) + 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 testBoundedInlineAvatarProfileRefreshStillResolvesDisplayName() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Inline avatar profile" + ) + let picture = "data:image/png;base64," + String(repeating: "A", count: 170_000) + let profileContent = try XCTUnwrap( + String( + data: JSONSerialization.data(withJSONObject: [ + "display_name": "Fizz", + "picture": picture, + ]), + encoding: .utf8 + ) + ) + let profile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 0, + content: profileContent + ) + let presentationData = try JSONEncoder().encode([profile]) + XCTAssertLessThan( + presentationData.count, + BuzzPushNotificationResolver.maximumPresentationResponseBytes + ) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + data: URLProtocolStub.requests.count == 1 + ? try JSONEncoder().encode([message]) : presentationData + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community()]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Fizz") + XCTAssertNil(result.senderAvatarPNG) + XCTAssertEqual(result.body, "Inline avatar profile") + 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, + policies: [ + PushResolutionPolicy( + filter: PushLeaseFilter( + kinds: [9, 40002, 45001, 45003], + hTags: [Self.channelID] + ) + ) + ] + ) + } + + 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..ed592c576f6 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -0,0 +1,748 @@ +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 inline raster profile retains its name and accepts a local thumbnail") + func verifiedInlineRasterProfileAndAvatar() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let picture = "data:image/png;base64," + String(repeating: "A", count: 170_000) + let content = try #require( + String( + data: JSONSerialization.data(withJSONObject: [ + "display_name": "Fizz", + "picture": picture, + ]), + encoding: .utf8 + ) + ) + let event = try signedEvent(privateKey: profileKey, kind: 0, content: content) + + let needsAvatar = try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: event)] + ) + #expect(needsAvatar == Set([event.id])) + #expect( + try loadSnapshot(directory).profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + )?.displayName == "Fizz" + ) + + let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + #expect( + try store.updateAvatar( + communityID: "community-a", + relayOrigin: "https://relay.example", + sourceURL: picture, + avatarPNG: png + ) + ) + #expect( + try loadSnapshot(directory).profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + )?.avatarPNG == png + ) + } + + @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..096737d5359 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift @@ -0,0 +1,84 @@ +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 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 policy = PushResolutionPolicy( + filter: PushLeaseFilter(kinds: [1059], pTags: [mine]) + ) + + XCTAssertTrue(PushLeaseMatcher.matches(event: event, policy: policy)) + } + + func testIgnoreAndHellthreadSuppressionRejectCandidates() { + let ignored = makeEvent(kind: 9, pubkey: other, tags: [["p", mine]]) + let ignorePolicy = PushResolutionPolicy( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]), + ignore: [PushLeaseFilter(kinds: [9], authors: [other])] + ) + XCTAssertFalse( + PushLeaseMatcher.matches(event: ignored, policy: ignorePolicy) + ) + + let hellthread = makeEvent( + kind: 9, + tags: (0..<21).map { ["p", String(format: "%064x", $0)] } + ) + let suppressed = PushResolutionPolicy( + filter: PushLeaseFilter(kinds: [9], authors: [other]), + suppress: PushLeaseSuppression(pTagsMax: 20) + ) + XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, policy: suppressed)) + } + + func testDecodesSnapshotContractFromDartShape() throws { + let json = """ + {"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","policies":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}]} + """ + let snapshot = try JSONDecoder().decode(PushLeaseSnapshot.self, from: Data(json.utf8)) + + XCTAssertEqual(snapshot.communities.count, 1) + XCTAssertEqual( + snapshot.communities[0].policies.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..8660e4e3ed0 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -1,12 +1,21 @@ #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 support is always present in the iOS artifact. The current relay's +// fully validated NIP-11 descriptor is the runtime activation authority. +BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) +BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) +BUZZ_IOS_PUSH_ENVIRONMENT = development +BUZZ_APP_ATTEST_ENVIRONMENT = development // Worktree-aware debug identity (gitignored, written by // scripts/mobile-worktree-overrides.sh): a per-worktree bundle identifier @@ -15,7 +24,6 @@ APP_DISPLAY_NAME = Buzz #include? "WorktreeOverrides.xcconfig" // Developer app-specific overrides are included last: xcconfig -// later-include-wins is per variable, so a personal BUNDLE_IDENTIFIER for -// device signing beats the worktree default while unset variables still -// fall through to the worktree values. +// later-include-wins is per variable, so a personal signing identity beats the +// worktree default while unset variables still fall through to tracked values. #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index d287c5fb432..cdacb9e89bc 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 + +// Push support is always present in the iOS artifact. Relay advertisement is +// the rollout authority, so a relay without a valid descriptor remains inert. +BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) +BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) +BUZZ_IOS_PUSH_ENVIRONMENT = production +BUZZ_APP_ATTEST_ENVIRONMENT = production #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..44965720a9f --- /dev/null +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -0,0 +1,139 @@ +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.loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier) + }, + loadPrivateKey: { communityID in + Self.loadPrivateKey( + communityID: communityID, + keychainAccessGroup: keychainAccessGroup + ) + }, + loadPresentationCacheData: { + Self.loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier) + } + ) + }() + + 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 loadPushSnapshotData(appGroupIdentifier: String?) -> Data? { + loadAppGroupData( + fileName: BuzzPushPresentationCacheStore.fileName, + appGroupIdentifier: appGroupIdentifier, + maximumBytes: BuzzPushPresentationCacheStore.maximumSnapshotBytes + ) + } + + 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 43df76f6810..29ce66de691 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 */; }; @@ -30,6 +34,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 /* PushSnapshotBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002A /* PushSnapshotBridge.swift */; }; + BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -47,6 +55,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; @@ -60,6 +79,11 @@ /* 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 = ""; }; 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 = ""; }; @@ -88,6 +112,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 /* PushSnapshotBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSnapshotBridge.swift; sourceTree = ""; }; + BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuzzCommunicationNotificationTests.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 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 = ""; }; @@ -104,6 +132,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; @@ -116,6 +152,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BZZ00000000000000000025 /* BuzzPushKit in Frameworks */, 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -127,6 +164,7 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */, 331C80A0294A618700263BE5 /* Fixtures */, ); path = RunnerTests; @@ -169,6 +207,7 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + BZZ0000000000000000000B /* NotificationService */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, @@ -183,6 +222,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + BZZ00000000000000000009 /* NotificationService.appex */, ); name = Products; sourceTree = ""; @@ -194,9 +234,13 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + BZZ0000000000000000000A /* Runner.entitlements */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + BZZ00000000000000000024 /* PushNativeState.swift */, + BZZ00000000000000000027 /* PushEndpointGrantStore.swift */, + BZZ0000000000000000002A /* PushSnapshotBridge.swift */, 331C809A294A618700263BE5 /* MediaSanitizer.swift */, 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */, 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */, @@ -227,6 +271,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 */ @@ -259,6 +313,7 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + BZZ00000000000000000004 /* Embed App Extensions */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */, ED5DDC1D42A9D342928222CC /* [CP] Copy Pods Resources */, @@ -268,10 +323,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 */ @@ -282,6 +360,9 @@ LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { + BZZ0000000000000000000E = { + CreatedOnToolsVersion = 15.0; + }; 331C8080294A63A400263BE5 = { CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; @@ -301,17 +382,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; @@ -447,11 +539,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; }; @@ -460,6 +561,9 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + BZZ00000000000000000023 /* PushNativeState.swift in Sources */, + BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */, + BZZ00000000000000000029 /* PushSnapshotBridge.swift in Sources */, 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */, 4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */, 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */, @@ -490,6 +594,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; @@ -569,8 +688,9 @@ 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; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -757,8 +877,9 @@ 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; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -780,8 +901,9 @@ 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; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -795,9 +917,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 1ef121fff4a..b3f89333996 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,11 +1,32 @@ import AVFoundation +import BuzzPushKit import Flutter import UIKit import UserNotifications +import os.log @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private var mediaUploadChannel: FlutterMethodChannel? + 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 var pushKeychainAccessGroup: String? { + Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + } + private lazy var pushSnapshotBridge = BuzzPushSnapshotBridge( + appGroupIdentifier: appGroupIdentifier, + endpointGrantStore: endpointGrantStore, + keychainAccessGroup: pushKeychainAccessGroup + ) private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? private var concentricSheetSurfaceChannel: FlutterMethodChannel? @@ -19,7 +40,7 @@ import UserNotifications _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in } + UNUserNotificationCenter.current().delegate = self return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -34,6 +55,16 @@ import UserNotifications mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in self?.handleMediaUploadMethodCall(call, result: result) } + 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) + } qrScannerChannel = FlutterMethodChannel( name: "buzz/qr_scanner", binaryMessenger: messenger @@ -164,7 +195,8 @@ import UserNotifications if #available(iOS 16.0, *), let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzNativeMessageActionSurface" - ) { + ) + { nativeMessageActionsRegistrar.register( NativeMessageActionSurfaceFactory(messenger: messenger), withId: "buzz/native_message_action_surface" @@ -233,6 +265,205 @@ import UserNotifications .safeAreaInsets.top ?? 0 } + 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 pushSnapshotBridge.handle(call, result: result) { + return + } + switch call.method { + case "startRegistration": + startPushRegistration(result: result) + case "takePendingNotificationResponse": + result(pushNavigationBuffer.take()?.flutterArguments) + 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 startPushRegistration(result: @escaping FlutterResult) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { + _, error in + if let error { + os_log( + "Buzz notification authorization request failed: %{public}@", + type: .error, + error.localizedDescription + ) + } + } + // APNs token registration is independent from display authorization. A + // denied or failed prompt must not prevent gateway enrollment and leases. + UIApplication.shared.registerForRemoteNotifications() + result(nil) + } + + 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 = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore, + appAttestKeychainAccessGroup: Bundle.main.object( + forInfoDictionaryKey: "BuzzKeychainAccessGroup" + ) as? String + ) + 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 handleMediaUploadMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult @@ -427,8 +658,7 @@ import UserNotifications ) destinationVideo.preferredTransform = sourceVideo.preferredTransform - if - let sourceAudio, + if let sourceAudio, let destinationAudio = composition.addMutableTrack( withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid @@ -550,7 +780,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] @@ -570,11 +801,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( @@ -670,3 +902,13 @@ import UserNotifications ) } } + +extension BuzzPushNavigationTarget { + fileprivate var flutterArguments: [String: String] { + [ + "eventId": eventID, + "communityId": communityID, + "channelId": channelID, + ] + } +} diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 3f93df5b97e..544f2517bdb 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 @@ -53,6 +57,10 @@ Buzz uses your photo library to select profile photos and images to attach to messages. NSPhotoLibraryAddUsageDescription Buzz needs permission to save images to your photo library. + NSUserActivityTypes + + INSendMessageIntent + PHPhotoLibraryPreventAutomaticLimitedAccessAlert UIApplicationSceneManifest diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift new file mode 100644 index 00000000000..9b9c5554567 --- /dev/null +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -0,0 +1,106 @@ +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 + } +} diff --git a/mobile/ios/Runner/PushNativeState.swift b/mobile/ios/Runner/PushNativeState.swift new file mode 100644 index 00000000000..6253f656ce4 --- /dev/null +++ b/mobile/ios/Runner/PushNativeState.swift @@ -0,0 +1,80 @@ +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 + } +} diff --git a/mobile/ios/Runner/PushSnapshotBridge.swift b/mobile/ios/Runner/PushSnapshotBridge.swift new file mode 100644 index 00000000000..5c7bfb5fac0 --- /dev/null +++ b/mobile/ios/Runner/PushSnapshotBridge.swift @@ -0,0 +1,271 @@ +import BuzzPushKit +import Flutter +import Foundation + +final class BuzzPushSnapshotBridge { + private let appGroupIdentifier: String? + private let endpointGrantStore: BuzzPushEndpointGrantKeychainStore + private let keychainAccessGroup: String? + private let queue = DispatchQueue( + label: "xyz.block.buzz.push-snapshot", + 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?, + endpointGrantStore: BuzzPushEndpointGrantKeychainStore, + keychainAccessGroup: String? + ) { + self.appGroupIdentifier = appGroupIdentifier + self.endpointGrantStore = endpointGrantStore + self.keychainAccessGroup = keychainAccessGroup + } + + @discardableResult + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) -> Bool { + guard call.method == "syncPushSnapshot", + let arguments = call.arguments as? [String: Any], + let section = arguments["section"] as? String + else { + return false + } + switch section { + case "communities": syncCommunities(arguments, result: result) + case "profiles": cacheProfiles(arguments, result: result) + case "channels": cacheChannels(arguments, result: result) + case "avatar": cacheAvatar(arguments, result: result) + default: return false + } + return true + } + + private func syncCommunities(_ arguments: [String: Any], result: @escaping FlutterResult) { + guard let communities = arguments["communities"] as? [[String: Any]], + let signingKeys = arguments["signingKeys"] as? [String: String], + communities.count <= BuzzPushPresentationCacheStore.maximumCommunities + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected bounded communities and signing keys.", + details: nil + ) + ) + return + } + queue.async { [weak self] in + do { + guard let self, let store else { + Self.complete(result, value: nil) + return + } + // Relay-metadata enrichment is optional presentation state. A damaged + // grant cache must not block the core NSE snapshot and key update. + let grants = (try? endpointGrantStore.records()) ?? [] + let enriched = communities.map { community -> [String: Any] in + var community = community + guard let relayURL = community["relayUrl"] as? String, + let relayMetadataPubkey = Self.relayMetadataPubkey( + relayURL: relayURL, + grants: grants + ) + else { return community } + community["relayMetadataPubkey"] = relayMetadataPubkey + return community + } + let data = try JSONSerialization.data(withJSONObject: enriched, options: [.sortedKeys]) + let decoded = try JSONDecoder().decode([PushLeaseCommunity].self, from: data) + try store.replaceCommunities(decoded) + try BuzzPushKeychain.replace( + signingKeys: signingKeys, + accessGroup: keychainAccessGroup + ) + Self.complete(result, value: nil) + } catch { + Self.complete( + result, + value: FlutterError( + code: "snapshot_sync_failed", + message: "Unable to sync push community state.", + details: error.localizedDescription + ) + ) + } + } + } + + static func relayMetadataPubkey( + relayURL: String, + grants: [BuzzPushEndpointGrantRecord] + ) -> String? { + guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else { + return nil + } + return grants.filter { + $0.appProfile == BuzzDevPushEnrollmentDriver.appProfile + && BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin + }.max { + $0.generation < $1.generation + }?.relayMetadataPubkey + } + + private func cacheProfiles(_ rawArguments: Any?, result: @escaping FlutterResult) { + 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(BuzzPushPresentationCacheStore.fileName)), + let snapshot = try? JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.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) + } + } +} diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements new file mode 100644 index 00000000000..7fca08a0f35 --- /dev/null +++ b/mobile/ios/Runner/Runner.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..156aa615210 --- /dev/null +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -0,0 +1,282 @@ +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) + XCTAssertNotNil(intent.image(forParameterNamed: \.speakableGroupName)) + 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.image(forParameterNamed: \.speakableGroupName)) + 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: "other-profile", + generation: 99, + metadataPubkey: String(repeating: "b", count: 64) + ) + + XCTAssertEqual( + BuzzPushSnapshotBridge.relayMetadataPubkey( + 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 655095045ef..a468eccc125 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -29,6 +29,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_relay_capability_provider.dart'; import 'shared/relay/relay.dart'; import 'shared/read_state/read_state_provider.dart'; import 'shared/theme/theme.dart'; @@ -325,6 +327,9 @@ class App extends HookConsumerWidget { ref.watch(observerRelayProvider); ref.watch(appLifecycleProvider); ref.watch(userStatusCacheProvider); + if (ref.watch(currentRelayPushDescriptorProvider).value != null) { + ref.watch(pushSubscriptionSyncProvider); + } hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0; } diff --git a/mobile/lib/features/channels/channel_member_snapshots.dart b/mobile/lib/features/channels/channel_member_snapshots.dart new file mode 100644 index 00000000000..33f266ba6b9 --- /dev/null +++ b/mobile/lib/features/channels/channel_member_snapshots.dart @@ -0,0 +1,37 @@ +part of 'channels_provider.dart'; + +extension on ChannelsNotifier { + void _cacheMemberSnapshots( + Iterable events, { + bool replaceAll = false, + }) { + final latestByChannelId = {}; + for (final event in events) { + final channelId = event.getTagValue('d'); + if (channelId == null) continue; + final current = latestByChannelId[channelId]; + if (current == null || event.createdAt > current.createdAt) { + latestByChannelId[channelId] = event; + } + } + + final snapshots = replaceAll + ? >{} + : Map>.of(_memberSnapshotsByChannelId); + snapshots.addAll({ + for (final entry in latestByChannelId.entries) + entry.key: List.unmodifiable([ + for (final member in membersFromEvent(entry.value)) + ChannelMember( + pubkey: member.pubkey, + role: member.role, + joinedAt: DateTime.fromMillisecondsSinceEpoch( + entry.value.createdAt * 1000, + isUtc: true, + ), + ), + ]), + }); + _memberSnapshotsByChannelId = Map.unmodifiable(snapshots); + } +} diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 03f29c58a1b..bd557ce9396 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'; @@ -20,6 +22,7 @@ import 'unread_badge/observed_unread_event.dart'; import 'unread_badge/should_notify_for_event.dart'; part 'channel_directory.dart'; +part 'channel_member_snapshots.dart'; const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; const _unreadCatchUpLimit = 1000; @@ -153,6 +156,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); @@ -203,11 +207,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. Extracted into the part file so // `channels_provider.dart` stays under the 1000-line ceiling enforced by @@ -275,6 +282,12 @@ class ChannelsNotifier extends AsyncNotifier> { // Use the membership snapshots already fetched above for both Huddle // linkage validation and member-count hydration. if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); + unawaited( + cacheBuzzPushChannelEvents(communityID, dedupedMetas, [ + ...memberships, + ...memberEvents, + ]), + ); final memberCounts = _memberCountsByChannelId(memberEvents); for (var i = 0; i < channels.length; i++) { final count = memberCounts[channels[i].id]; @@ -382,40 +395,6 @@ class ChannelsNotifier extends AsyncNotifier> { return channels; } - void _cacheMemberSnapshots( - Iterable events, { - bool replaceAll = false, - }) { - final latestByChannelId = {}; - for (final event in events) { - final channelId = event.getTagValue('d'); - if (channelId == null) continue; - final current = latestByChannelId[channelId]; - if (current == null || event.createdAt > current.createdAt) { - latestByChannelId[channelId] = event; - } - } - - final snapshots = replaceAll - ? >{} - : Map>.of(_memberSnapshotsByChannelId); - snapshots.addAll({ - for (final entry in latestByChannelId.entries) - entry.key: List.unmodifiable([ - for (final member in membersFromEvent(entry.value)) - ChannelMember( - pubkey: member.pubkey, - role: member.role, - joinedAt: DateTime.fromMillisecondsSinceEpoch( - entry.value.createdAt * 1000, - isUtc: true, - ), - ), - ]), - }); - _memberSnapshotsByChannelId = Map.unmodifiable(snapshots); - } - /// Fetches each channel's independent latest-message window in one HTTP /// bridge request. The relay preserves NIP-01 per-filter limits while /// executing the filters with bounded concurrency, avoiding an unbounded diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index a7f509de13b..9d3c0fdc8a4 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -74,7 +74,43 @@ class _DeepLinkDispatcherState extends ConsumerState { !widget.dispatchMessageLinks) { return; } + if (link is MessageDeepLink) { + unawaited(_dispatchNotificationLink(link)); + return; + } + + _dispatchNavigableLink(link); + } + + Future _dispatchNotificationLink(MessageDeepLink link) async { + final preparation = await ref + .read(pendingDeepLinkProvider.notifier) + .prepareCommunity(link); + if (!mounted || ref.read(pendingDeepLinkProvider) != link) return; + switch (preparation) { + case DeepLinkCommunityPreparation.ready: + _dispatchNavigableLink(link); + case DeepLinkCommunityPreparation.switched: + // The community-scoped app subtree remounts and consumes the parked + // target after its channels load. + return; + case DeepLinkCommunityPreparation.unavailable: + ref.read(pendingDeepLinkProvider.notifier).consume(); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Notification community is no longer available'), + ), + ); + case DeepLinkCommunityPreparation.failed: + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Could not open the notification community'), + ), + ); + } + } + void _dispatchNavigableLink(BuzzDeepLink link) { final channelId = switch (link) { MessageDeepLink(:final channelId) => channelId, ChannelDeepLink(:final channelId) => channelId, diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 83ded086f20..8f360a3db91 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -4,10 +4,16 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; import 'features/invites/invite_join_provider.dart'; +import 'shared/push/push_bootstrap.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(); + installBuzzPushMethodHandler(); + await syncPendingBuzzPushNotificationResponse(); // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); @@ -21,7 +27,7 @@ void main() async { (scope) => buildMobileInviteJoinRecovery(ref, scope), ), ], - child: const App(), + child: BuzzPushBootstrap(child: app), ), ); } diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart index ef79934e3fe..a837b7b56ac 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); } @@ -59,6 +62,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); @@ -76,13 +80,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 6763f953ee5..20198869d40 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; /// Whether invite-created starter channels still need to be recovered. final bool starterSetupIncomplete; @@ -24,6 +27,7 @@ class Community { this.pubkey, this.nsec, this.sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser, + this.pushSubscriptionState = const BuzzPushLeaseSubscriptionState.desired(), this.starterSetupIncomplete = false, required this.addedAt, }); @@ -55,6 +59,7 @@ class Community { Object? pubkey = _sentinel, Object? nsec = _sentinel, SensitiveActionPolicy? sensitiveActionPolicy, + BuzzPushLeaseSubscriptionState? pushSubscriptionState, bool? starterSetupIncomplete, }) { return Community( @@ -65,6 +70,8 @@ class Community { nsec: nsec == _sentinel ? this.nsec : nsec as String?, sensitiveActionPolicy: sensitiveActionPolicy ?? this.sensitiveActionPolicy, + pushSubscriptionState: + pushSubscriptionState ?? this.pushSubscriptionState, starterSetupIncomplete: starterSetupIncomplete ?? this.starterSetupIncomplete, addedAt: addedAt, @@ -78,6 +85,7 @@ class Community { if (pubkey != null) 'pubkey': pubkey, if (nsec != null) 'nsec': nsec, 'sensitiveActionPolicy': sensitiveActionPolicy.name, + 'pushSubscriptionState': pushSubscriptionState.toJson(), 'starterSetupIncomplete': starterSetupIncomplete, 'addedAt': addedAt.toIso8601String(), }; @@ -92,6 +100,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), + ), starterSetupIncomplete: json['starterSetupIncomplete'] as bool? ?? false, 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 ce03de7003a..d853e60d4e6 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -1,8 +1,13 @@ import 'dart:developer' as developer; 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_subscription.dart'; +import '../relay/signed_event_relay.dart'; import 'community.dart'; import 'community_storage.dart'; @@ -69,11 +74,135 @@ 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 { + final state = community.pushSubscriptionState; + final acceptedGeneration = state.acceptedGeneration; + final nsec = community.nsec; + if (acceptedGeneration == null || nsec == null || nsec.isEmpty) { + return; + } + try { + final decoded = nostr.Nip19.decode(payload: nsec); + final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public; + final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl); + final installationId = (await readBuzzPushEndpointGrants()) + .where( + (grant) => + grant.relayOrigin == descriptor.origin && + grant.appProfile == buzzDevPushAppProfile, + ) + .map((grant) => grant.installationId) + .firstOrNull; + if (installationId == null) return; + final uri = Uri.parse(community.relayUrl); + final httpScheme = switch (uri.scheme) { + 'wss' => 'https', + '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 @@ -97,11 +226,14 @@ 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; } @@ -112,10 +244,20 @@ class CommunityListNotifier extends AsyncNotifier> { if (activeId == id) { await ref.read(communityTransitionProvider).run(); } + 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. if (activeId == id) { @@ -152,6 +294,56 @@ class CommunityListNotifier extends AsyncNotifier> { }); } + 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, + }) 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, + ), + ); + 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 ?? []; @@ -164,6 +356,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..70f1a6a3b6f 100644 --- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart +++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart @@ -6,6 +6,10 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'deep_link.dart'; +import '../community/community_provider.dart'; +import '../push/push_bridge.dart'; + +enum DeepLinkCommunityPreparation { ready, switched, unavailable, failed } /// Holds supported deep links until they can be dispatched. /// @@ -22,17 +26,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 +56,53 @@ 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(); } + + /// Selects the device-local community carried by a structured push target. + /// Ordinary shared deep links have no community ID and remain unchanged. + Future prepareCommunity( + BuzzDeepLink link, + ) async { + if (link is! MessageDeepLink || link.communityId == null) { + return DeepLinkCommunityPreparation.ready; + } + final communityId = link.communityId!; + try { + final communities = await ref.read(communityListProvider.future); + if (!communities.any((community) => community.id == communityId)) { + return DeepLinkCommunityPreparation.unavailable; + } + final active = await ref.read(activeCommunityProvider.future); + if (active?.id == communityId) return DeepLinkCommunityPreparation.ready; + await ref + .read(communityListProvider.notifier) + .switchCommunity(communityId); + return DeepLinkCommunityPreparation.switched; + } catch (error) { + debugPrint( + 'notification-routing: failed to switch to community ' + '$communityId: $error', + ); + return DeepLinkCommunityPreparation.failed; + } + } + + void _enqueue(BuzzDeepLink link) { + if (state == null) { + state = link; + } 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 d11db73b1cf..c974b4a055f 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'; @@ -120,6 +122,7 @@ class UserCacheNotifier extends Notifier> { var succeeded = false; try { + final communityID = ref.read(activeCommunityProvider).value?.id; final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory( NostrFilters.profilesBatch(pubkeys), @@ -137,6 +140,9 @@ class UserCacheNotifier extends Notifier> { ..clear() ..addAll(updatedOrders); state = updated; + if (communityID != null) { + unawaited(cacheBuzzPushProfileEvents(communityID, events)); + } succeeded = true; } catch (_) { // Silently fail — non-gating callers will 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..a999ae2903d --- /dev/null +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -0,0 +1,648 @@ +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', + 'h_grammar', + 'class_support', + 'limitation', + }, + allowed: const { + 'origin', + 'keys', + 'app_profiles', + 'push_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 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 = {'default'}; + 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(), + ], + }; + 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, + }; + 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 _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..bbd5bd73478 --- /dev/null +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -0,0 +1,270 @@ +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_relay_capability_provider.dart'; +import 'push_subscription.dart'; + +const _pushBootstrapRetryDelay = Duration(seconds: 5); + +@visibleForTesting +class BuzzPushAttemptGate { + BuzzPushAttemptGate({this.retryDelay = _pushBootstrapRetryDelay}); + + final Duration retryDelay; + String? _attempt; + Timer? _retryTimer; + + bool tryBegin(String attempt) { + if (_attempt == attempt) return false; + _retryTimer?.cancel(); + _retryTimer = null; + _attempt = attempt; + return true; + } + + void failed(String attempt, {required VoidCallback retry}) { + if (_attempt != attempt) return; + _attempt = null; + _retryTimer?.cancel(); + _retryTimer = Timer(retryDelay, () { + _retryTimer = null; + if (_attempt == null) retry(); + }); + } + + void retryAfter( + String attempt, { + required Duration delay, + required VoidCallback retry, + }) { + if (_attempt != attempt) return; + _retryTimer?.cancel(); + _retryTimer = Timer(delay, () { + _retryTimer = null; + if (_attempt != attempt) return; + _attempt = null; + retry(); + }); + } + + void dispose() => _retryTimer?.cancel(); +} + +@visibleForTesting +String buzzPushPublicationAttemptKey({ + required String communityId, + required String relayBaseUrl, + required String token, + required BuzzPushLeaseDescriptor descriptor, + required List subscriptions, +}) => [ + communityId, + relayBaseUrl, + token, + descriptor.executorKeyId, + descriptor.executorPubkey, + buzzPushSubscriptionsFingerprint(subscriptions), +].join('|'); + +/// 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); + final registrationAttempt = useMemoized(BuzzPushAttemptGate.new); + final publicationAttempt = useMemoized(BuzzPushAttemptGate.new); + final registrationRetry = useState(0); + final publicationRetry = useState(0); + final session = ref.watch(relaySessionProvider); + final config = ref.watch(relayConfigProvider); + final community = ref.watch(activeCommunityProvider).value; + final memberPubkey = ref.watch(myPubkeyProvider); + final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; + + useEffect( + () => () { + registrationAttempt.dispose(); + publicationAttempt.dispose(); + }, + const [], + ); + + useEffect( + () { + if (!_ready(session, config, community, memberPubkey) || + descriptor == null) { + return null; + } + final attempt = '${community!.id}|${config.baseUrl}'; + if (!registrationAttempt.tryBegin(attempt)) return null; + unawaited(() async { + try { + await startBuzzPushRegistrationIfCapable( + descriptor, + startRegistration: startBuzzPushRegistration, + ); + } catch (error, stack) { + registrationAttempt.failed( + attempt, + retry: () { + if (context.mounted) registrationRetry.value += 1; + }, + ); + debugPrint('Push registration bootstrap failed: $error'); + debugPrintStack(stackTrace: stack); + } + }()); + return null; + }, + [ + session.status, + config.baseUrl, + community?.id, + memberPubkey, + descriptor, + registrationRetry.value, + ], + ); + + final token = apnsDeviceToken.value; + useEffect( + () { + if (!_ready(session, config, community, memberPubkey) || + descriptor == null || + token == null) { + return null; + } + final state = community!.pushSubscriptionState; + if (state.desired.isEmpty) return null; + final attempt = buzzPushPublicationAttemptKey( + communityId: community.id, + relayBaseUrl: config.baseUrl, + token: token, + descriptor: descriptor, + subscriptions: state.desired, + ); + if (!publicationAttempt.tryBegin(attempt)) return null; + final relay = SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: config.nsec!, + ); + unawaited(() async { + try { + final grant = await _publish( + ref, + config, + community, + memberPubkey!, + relay, + ); + final renewInMilliseconds = + grant.expiresAt * 1000 - + DateTime.now().millisecondsSinceEpoch - + const Duration(minutes: 5).inMilliseconds; + publicationAttempt.retryAfter( + attempt, + delay: Duration( + milliseconds: renewInMilliseconds > 1000 + ? renewInMilliseconds + : 1000, + ), + retry: () { + if (context.mounted) publicationRetry.value += 1; + }, + ); + } catch (error, stack) { + publicationAttempt.failed( + attempt, + retry: () { + if (context.mounted) publicationRetry.value += 1; + }, + ); + debugPrint('Push lease bootstrap failed: $error'); + debugPrintStack(stackTrace: stack); + } + }()); + return null; + }, + [ + session.status, + config.baseUrl, + community?.id, + community?.pushSubscriptionState, + memberPubkey, + descriptor, + token, + publicationRetry.value, + ], + ); + + 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 _publish( + WidgetRef ref, + RelayConfig config, + Community community, + String memberPubkey, + SignedEventRelay relay, + ) async { + final state = community.pushSubscriptionState; + final desired = state.desired; + final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); + final grant = await enrollBuzzPush( + config.wsUrl, + Env.pushGatewayUrl, + communitiesForSnapshotRefresh: + ref.read(communityListProvider).value ?? [community], + ); + // 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, + ); + return grant; + } +} diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart new file mode 100644 index 00000000000..513971fcf62 --- /dev/null +++ b/mobile/lib/shared/push/push_bridge.dart @@ -0,0 +1,248 @@ +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 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. + } +} + +/// Starts the independent iOS notification-authorization and APNs-registration +/// requests. Display authorization is intentionally not returned or persisted: +/// APNs registration and enrollment remain valid while display is denied. +Future startBuzzPushRegistration() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + await _channel.invokeMethod('startRegistration'); + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + +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), + subscriptions: community.pushSubscriptionState.authoritative, + ), + ]; + 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('syncPushSnapshot', { + 'section': 'communities', + '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_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart new file mode 100644 index 00000000000..82cc78a89d2 --- /dev/null +++ b/mobile/lib/shared/push/push_presentation_cache.dart @@ -0,0 +1,240 @@ +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'; + +const _pushPresentationChannel = MethodChannel('buzz/push'); +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 (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { + return; + } + final verified = _newestVerifiedEvents( + events, + kind: 0, + scope: (event) => event.pubkey.toLowerCase(), + ).values.toList(); + if (verified.isEmpty) return; + await _invokeBestEffort({ + 'section': 'profiles', + '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 (defaultTargetPlatform != TargetPlatform.iOS || + communityID == null || + communityID.isEmpty) { + return; + } + final batch = selectPushChannelEvents(metadataEvents, membershipEvents); + final verifiedMetadata = batch.metadata; + final verifiedMembership = batch.membership; + if (verifiedMetadata.isEmpty && verifiedMembership.isEmpty) return; + await _invokeBestEffort({ + 'section': 'channels', + 'communityId': communityID, + 'metadataEvents': [for (final event in verifiedMetadata) event.toJson()], + 'membershipEvents': [ + for (final event in verifiedMembership) event.toJson(), + ], + }); +} + +/// Selects the newest paired verified channel metadata and membership events. +@visibleForTesting +({List metadata, List membership}) +selectPushChannelEvents( + Iterable metadataEvents, + Iterable membershipEvents, +) { + final verifiedMembershipByChannel = _newestVerifiedEvents( + membershipEvents, + kind: 39002, + scope: (event) => event.getTagValue('d'), + ); + final selectedChannelIDs = verifiedMembershipByChannel.keys.toSet(); + final verifiedMetadataByChannel = _newestVerifiedEvents( + metadataEvents, + kind: 39000, + 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 _newestVerifiedEvents( + Iterable events, { + required int kind, + required String? Function(NostrEvent event) scope, + Set? allowedScopes, +}) { + final 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; + } + selected[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 (defaultTargetPlatform != TargetPlatform.iOS || + communityID.isEmpty || + sourceBytes.isEmpty || + sourceBytes.length > _maximumAvatarSourceBytes || + !isCacheablePushAvatarSource(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({ + 'section': 'avatar', + 'communityId': communityID, + 'sourceUrl': sourceURL, + 'png': png, + }); + } finally { + release.complete(); + } +} + +Future _invokeBestEffort(Map arguments) async { + try { + await _pushPresentationChannel.invokeMethod( + 'syncPushSnapshot', + arguments, + ); + pushPresentationCacheError.value = null; + } on MissingPluginException { + // Non-Runner embeddings do not provide the native snapshot bridge. + } catch (error, stackTrace) { + pushPresentationCacheError.value = error.toString(); + debugPrint('Push presentation cache update failed: $error'); + debugPrintStack(stackTrace: stackTrace); + } +} + +@visibleForTesting +bool isCacheablePushAvatarSource(String value) { + final trimmed = value.trim(); + if (trimmed.startsWith('data:image/')) { + try { + final data = UriData.parse(trimmed); + return data.mimeType.startsWith('image/') && + data.mimeType != 'image/svg+xml' && + data.contentAsBytes().isNotEmpty; + } on FormatException { + return false; + } + } + 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_relay_capability_provider.dart b/mobile/lib/shared/push/push_relay_capability_provider.dart new file mode 100644 index 00000000000..f2e8eebd8be --- /dev/null +++ b/mobile/lib/shared/push/push_relay_capability_provider.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../community/community_provider.dart'; +import '../relay/relay_provider.dart'; +import '../relay/relay_session.dart'; +import 'dev_push_lease.dart'; + +typedef BuzzPushDescriptorFetcher = + Future Function(String relayBaseUrl); + +final buzzPushDescriptorFetcherProvider = Provider( + (ref) => fetchBuzzPushLeaseDescriptor, +); + +/// The fully validated push capability advertised by the current relay. +/// +/// Discovery fails closed. An absent, malformed, or unreachable NIP-11 push +/// descriptor is represented as no capability, so no notification permission, +/// APNs registration, gateway enrollment, or relay lease can begin. +final currentRelayPushDescriptorProvider = + FutureProvider.autoDispose((ref) async { + final session = ref.watch(relaySessionProvider); + final config = ref.watch(relayConfigProvider); + final community = ref.watch(activeCommunityProvider).value; + final memberPubkey = ref.watch(myPubkeyProvider); + if (session.status != SessionStatus.connected || + community == null || + config.nsec == null || + config.nsec!.isEmpty || + memberPubkey == null || + memberPubkey.isEmpty) { + return null; + } + + return discoverBuzzPushRelayCapability( + config.baseUrl, + fetchDescriptor: ref.read(buzzPushDescriptorFetcherProvider), + ); + }); + +Future discoverBuzzPushRelayCapability( + String relayBaseUrl, { + required BuzzPushDescriptorFetcher fetchDescriptor, +}) async { + try { + return await fetchDescriptor(relayBaseUrl); + } catch (error, stackTrace) { + debugPrint('Current relay does not advertise valid push: $error'); + debugPrintStack(stackTrace: stackTrace); + return null; + } +} + +Future startBuzzPushRegistrationIfCapable( + BuzzPushLeaseDescriptor? descriptor, { + required Future Function() startRegistration, +}) async { + if (descriptor == null) return; + await startRegistration(); +} diff --git a/mobile/lib/shared/push/push_snapshot.dart b/mobile/lib/shared/push/push_snapshot.dart new file mode 100644 index 00000000000..f269d05c0c5 --- /dev/null +++ b/mobile/lib/shared/push/push_snapshot.dart @@ -0,0 +1,53 @@ +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 List subscriptions; + + BuzzPushCommunitySnapshot({ + required this.id, + required this.name, + required this.relayUrl, + this.pubkey, + required Iterable subscriptions, + }) : subscriptions = List.unmodifiable(subscriptions); + + Map toJson() => { + 'id': id, + 'name': name, + 'relayUrl': relayUrl, + if (pubkey != null) 'pubkey': pubkey, + 'policies': [ + for (final subscription in subscriptions) + { + 'filter': subscription.filter.toJson(), + if (subscription.ignore.isNotEmpty) + 'ignore': [ + for (final filter in subscription.ignore) filter.toJson(), + ], + if (subscription.suppress != null) + 'suppress': subscription.suppress!.toJson(), + }, + ], + }; + + factory BuzzPushCommunitySnapshot.fromJson(Map json) { + return BuzzPushCommunitySnapshot( + id: json['id'] as String, + name: json['name'] as String, + relayUrl: json['relayUrl'] as String, + pubkey: json['pubkey'] as String?, + subscriptions: [ + for (final raw in json['policies'] as List) + BuzzPushSubscription.fromJson({ + ...Map.from(raw as Map), + 'class': 'default', + }), + ], + ); + } +} diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart new file mode 100644 index 00000000000..277031e84a1 --- /dev/null +++ b/mobile/lib/shared/push/push_subscription.dart @@ -0,0 +1,424 @@ +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 = {'default'}; +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; + + const BuzzPushLeaseSubscriptionState.desired({ + this.desired = const [], + this.accepted, + this.acceptedGeneration, + }) : authority = BuzzPushLeaseSubscriptionAuthority.desired; + + BuzzPushLeaseSubscriptionState.accepted({ + required Iterable desired, + required Iterable acceptedSubscriptions, + required this.acceptedGeneration, + }) : 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.', + ); + } + } + + 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, + ), + BuzzPushLeaseSubscriptionAuthority.accepted => + BuzzPushLeaseSubscriptionState.accepted( + desired: updated, + acceptedSubscriptions: accepted!, + acceptedGeneration: acceptedGeneration, + ), + }; + } + + BuzzPushLeaseSubscriptionState withAccepted({ + required Iterable subscriptions, + required int generation, + }) => BuzzPushLeaseSubscriptionState.accepted( + desired: desired, + acceptedSubscriptions: subscriptions, + acceptedGeneration: generation, + ); + + 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, + }; + + factory BuzzPushLeaseSubscriptionState.fromJson(Map json) { + _rejectUnknownKeys(json, const { + 'authority', + 'desired', + 'accepted', + 'acceptedGeneration', + }, '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']; + if (acceptedGeneration != null && acceptedGeneration is! int) { + throw const FormatException( + 'Accepted push lease generation must be an integer.', + ); + } + return switch (authority) { + 'desired' => BuzzPushLeaseSubscriptionState.desired( + desired: desired, + accepted: accepted, + acceptedGeneration: acceptedGeneration as int?, + ), + 'accepted' when accepted != null && acceptedGeneration is int => + BuzzPushLeaseSubscriptionState.accepted( + desired: desired, + acceptedSubscriptions: accepted, + acceptedGeneration: acceptedGeneration, + ), + '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 0a5379cf333..b869bf8fbc4 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -1,12 +1,16 @@ +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 '../emoji/emoji_avatar.dart'; import '../emoji/native_emoji_glyph.dart'; +import '../push/push_presentation_cache.dart'; import '../relay/relay.dart'; /// A circular avatar that supports both remote URLs and inline image data. @@ -52,7 +56,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; @@ -65,11 +69,12 @@ 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); + String? _scheduledPushAvatar; @override void didUpdateWidget(AvatarImageContent oldWidget) { @@ -82,6 +87,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( @@ -105,19 +111,50 @@ class _AvatarImageContentState extends State { placeholderBuilder: (_) => centeredFallback, errorBuilder: (_, _, _) => centeredFallback, ), - _RasterDataAvatarSource(:final bytes) => Image.memory( - bytes, - fit: widget.fit, - errorBuilder: (_, _, _) => centeredFallback, + _RasterDataAvatarSource(:final bytes) => _rasterImage( + communityID: communityID, + sourceURL: widget.imageUrl, + bytes: bytes, + fallback: centeredFallback, ), _NetworkAvatarSource(:final url) => MediaImage( url: url, fit: widget.fit, + onBytesLoaded: communityID == null + ? null + : (bytes) => unawaited( + cacheBuzzPushAvatarFromLoadedBytes(communityID, url, bytes), + ), errorBuilder: (_, _, _) => centeredFallback, ), null => centeredFallback, }; } + + Widget _rasterImage({ + required String? communityID, + required String? sourceURL, + required Uint8List bytes, + required Widget fallback, + }) { + if (communityID != null && sourceURL != null) { + final identity = '$communityID\u0000$sourceURL'; + if (_scheduledPushAvatar != identity) { + _scheduledPushAvatar = identity; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _scheduledPushAvatar != identity) return; + unawaited( + cacheBuzzPushAvatarFromLoadedBytes(communityID, sourceURL, bytes), + ); + }); + } + } + return Image.memory( + bytes, + fit: widget.fit, + errorBuilder: (_, _, _) => fallback, + ); + } } sealed class _AvatarSource { diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index a26308b284e..da1746df06c 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -157,6 +157,74 @@ 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( + key: const ValueKey('before-community-switch'), + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(await storage.loadActiveId(), _notificationCommunity.id); + expect(container.read(pendingDeepLinkProvider), link); + + // Production remounts the community-scoped app subtree after a switch. + // The parked link is consumed by the replacement dispatcher. + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: DeepLinkDispatcher( + key: const ValueKey('after-community-switch'), + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final destination = tester.widget<_CapturedDestination>( + find.byType(_CapturedDestination), + ); + expect(destination.link, link); + }); + testWidgets('retains invite and surfaces prepare failure', (tester) async { const link = InviteDeepLink( relayUrl: 'wss://relay.example.com', @@ -619,6 +687,19 @@ 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), +); final _welcomeEveryoneChannel = Channel( id: 'welcome-everyone-id', name: 'welcome-everyone', diff --git a/mobile/test/shared/auth/auth_provider_test.dart b/mobile/test/shared/auth/auth_provider_test.dart index 72c42cb3ed1..be1f0ec4b60 100644 --- a/mobile/test/shared/auth/auth_provider_test.dart +++ b/mobile/test/shared/auth/auth_provider_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +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; @@ -7,6 +8,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'; @@ -137,8 +139,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); @@ -147,9 +157,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 5396822b319..aad07d139f1 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -1,10 +1,12 @@ import 'dart:async'; +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'; @@ -12,17 +14,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); + }), + ], ); } @@ -31,6 +47,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 { @@ -63,6 +113,7 @@ void main() { final communities = await container.read(communityListProvider.future); expect(communities, isEmpty); + expect(deactivatedCommunityIds, [ws.id]); }); test( 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..7f7ea4b79e6 --- /dev/null +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -0,0 +1,385 @@ +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('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 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], + 'h_grammar': 'uuid-v4-lowercase', + 'class_support': { + 'apns': ['default'], + }, + '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_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart new file mode 100644 index 00000000000..ca860441ae4 --- /dev/null +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -0,0 +1,98 @@ +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_bootstrap.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('failed bootstrap attempt becomes retryable after the delay', () async { + final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); + addTearDown(gate.dispose); + var retries = 0; + + expect(gate.tryBegin('attempt'), isTrue); + gate.failed('attempt', retry: () => retries += 1); + await Future.delayed(Duration.zero); + + expect(retries, 1); + expect(gate.tryBegin('attempt'), isTrue); + }); + + test('a new attempt cancels an obsolete scheduled retry', () async { + final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); + addTearDown(gate.dispose); + var retries = 0; + + expect(gate.tryBegin('old'), isTrue); + gate.failed('old', retry: () => retries += 1); + expect(gate.tryBegin('new'), isTrue); + await Future.delayed(Duration.zero); + + expect(retries, 0); + expect(gate.tryBegin('new'), isFalse); + }); + + test('successful bootstrap becomes retryable at renewal time', () async { + final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); + addTearDown(gate.dispose); + var retries = 0; + + expect(gate.tryBegin('attempt'), isTrue); + gate.retryAfter('attempt', delay: Duration.zero, retry: () => retries += 1); + await Future.delayed(Duration.zero); + + expect(retries, 1); + expect(gate.tryBegin('attempt'), isTrue); + }); + + test('publication attempt changes when the relay executor rotates', () { + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), + notificationClass: 'default', + ); + final original = buzzPushPublicationAttemptKey( + communityId: 'community', + relayBaseUrl: 'https://relay.example', + token: 'token', + descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')), + subscriptions: [subscription], + ); + + expect( + buzzPushPublicationAttemptKey( + communityId: 'community', + relayBaseUrl: 'https://relay.example', + token: 'token', + descriptor: _descriptor(keyId: 'relay-v2', pubkey: _hex('b')), + subscriptions: [subscription], + ), + isNot(original), + ); + expect( + buzzPushPublicationAttemptKey( + communityId: 'community', + relayBaseUrl: 'https://relay.example', + token: 'token', + descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('c')), + subscriptions: [subscription], + ), + isNot(original), + ); + }); +} + +BuzzPushLeaseDescriptor _descriptor({ + required String keyId, + required String pubkey, +}) => BuzzPushLeaseDescriptor( + origin: 'wss://relay.example', + executorKeyId: keyId, + executorPubkey: pubkey, + transport: 'apns', + maxLeaseTtlSeconds: 3600, + maxContentLength: 4096, + maxPlaintextLength: 4096, + maxEndpointLength: 2048, + maxStringLength: 512, +); + +String _hex(String character) => List.filled(64, character).join(); 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..6c0a5a938d6 --- /dev/null +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -0,0 +1,314 @@ +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; + 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( + 'starts native permission and APNs registration without a result gate', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'startRegistration'); + return null; + }); + + await startBuzzPushRegistration(); + }, + ); + + 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 == 'syncPushSnapshot') { + 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', + 'syncPushSnapshot', + ]); + expect(snapshotArguments, [ + { + 'section': 'communities', + 'communities': [ + { + 'id': 'community-id', + 'name': 'Community', + 'relayUrl': 'wss://relay.example/', + 'pubkey': 'd' * 64, + 'policies': [], + }, + ], + '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..fff3ef72571 --- /dev/null +++ b/mobile/test/shared/push/push_presentation_cache_test.dart @@ -0,0 +1,144 @@ +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('channel selection keeps newest metadata paired with rosters', () { + NostrEvent signedChannelEvent(int kind, String channelID, int createdAt) { + final signed = nostr.Event.from( + kind: kind, + content: '', + tags: [ + ['d', channelID], + if (kind == 39002) + [ + 'p', + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + ], + ], + secretKey: secretKey, + createdAt: createdAt, + ); + return NostrEvent.fromJson(signed.toMap()); + } + + final batch = selectPushChannelEvents( + [ + signedChannelEvent(39000, 'channel-0', 100), + signedChannelEvent(39000, 'channel-1', 300), + signedChannelEvent(39000, 'channel-1', 200), + signedChannelEvent(39000, 'metadata-only', 400), + ], + [ + signedChannelEvent(39002, 'channel-0', 300), + signedChannelEvent(39002, 'channel-1', 200), + ], + ); + + expect(batch.metadata.map((event) => event.getTagValue('d')).toSet(), { + 'channel-0', + 'channel-1', + }); + expect(batch.membership.map((event) => event.getTagValue('d')).toSet(), { + 'channel-0', + 'channel-1', + }); + }); + + test('rejects changed content and malformed signatures', () { + final signed = nostr.Event.from( + 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_relay_capability_provider_test.dart b/mobile/test/shared/push/push_relay_capability_provider_test.dart new file mode 100644 index 00000000000..f6cc5874bac --- /dev/null +++ b/mobile/test/shared/push/push_relay_capability_provider_test.dart @@ -0,0 +1,73 @@ +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_relay_capability_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'valid capability starts independent permission and APNs registration', + () async { + var requests = 0; + + await startBuzzPushRegistrationIfCapable( + _descriptor, + startRegistration: () async { + requests += 1; + }, + ); + + expect(requests, 1); + }, + ); + + test( + 'missing capability cannot start permission or APNs registration', + () async { + var requests = 0; + + await startBuzzPushRegistrationIfCapable( + null, + startRegistration: () async { + requests += 1; + }, + ); + + expect(requests, 0); + }, + ); + + for (final failure in [ + const FormatException('malformed descriptor'), + StateError('relay unreachable'), + ]) { + test('$failure keeps capability inactive without registration', () async { + final descriptor = await discoverBuzzPushRelayCapability( + 'https://relay.example', + fetchDescriptor: (_) async => throw failure, + ); + var requests = 0; + + await startBuzzPushRegistrationIfCapable( + descriptor, + startRegistration: () async { + requests += 1; + }, + ); + + expect(descriptor, isNull); + expect(requests, 0); + }); + } +} + +const _descriptor = BuzzPushLeaseDescriptor( + origin: 'wss://relay.example', + executorKeyId: 'relay-v1', + executorPubkey: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + transport: 'apns', + maxLeaseTtlSeconds: 3600, + maxContentLength: 4096, + maxPlaintextLength: 4096, + maxEndpointLength: 2048, + maxStringLength: 512, +); 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..27c2c5ad217 --- /dev/null +++ b/mobile/test/shared/push/push_snapshot_test.dart @@ -0,0 +1,23 @@ +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 flattened resolution policies', () { + final subscription = buildDesiredBuzzPushSubscriptions( + myPubkey: 'a' * 64, + ).single; + final snapshot = BuzzPushCommunitySnapshot( + id: 'community', + name: 'Team', + relayUrl: 'https://relay.example.com', + pubkey: 'a' * 64, + subscriptions: [subscription], + ); + + final decoded = BuzzPushCommunitySnapshot.fromJson(snapshot.toJson()); + + expect(decoded.toJson(), snapshot.toJson()); + expect(decoded.subscriptions, hasLength(1)); + }); +} 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..359a3496df3 --- /dev/null +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -0,0 +1,137 @@ +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('persists only the relay-accepted lease generation', () { + final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; + final state = BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 9); + + final decoded = BuzzPushLeaseSubscriptionState.fromJson(state.toJson()); + expect(decoded.acceptedGeneration, 9); + expect(decoded.toJson(), state.toJson()); + }); + + 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/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index 4631cf58dab..821de63a480 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; +import 'package:buzz/shared/push/push_presentation_cache.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -23,6 +24,15 @@ void main() { ), ); + test('accepts bounded raster data avatars for the push cache', () { + expect(isCacheablePushAvatarSource('data:image/png;base64,AA=='), isTrue); + expect( + isCacheablePushAvatarSource('data:image/svg+xml;base64,AA=='), + isFalse, + ); + expect(isCacheablePushAvatarSource('data:image/png;base64,%%%'), isFalse); + }); + testWidgets('renders raccoon percent-encoded SVG data avatar', ( tester, ) async { 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 2390954ba1a..e43ef1e2794 100755 --- a/scripts/mobile-worktree-overrides.sh +++ b/scripts/mobile-worktree-overrides.sh @@ -75,7 +75,7 @@ case "$android_slug" in [0-9]*) android_slug="w_$android_slug" ;; esac -ios_bundle_id="com.buzz.buzzMobile.${ios_slug}" +ios_bundle_id="xyz.block.buzz.dogfood.mobile.${ios_slug}" android_app_name="${BUZZ_ANDROID_DEBUG_APP_NAME:-Buzz (${label})}" android_suffix="${BUZZ_ANDROID_DEBUG_ID_SUFFIX:-.${android_slug}}" diff --git a/scripts/test-mobile-worktree-overrides.sh b/scripts/test-mobile-worktree-overrides.sh index 8a33acd6358..70637f52259 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" \ @@ -86,7 +86,7 @@ printf '%s' "$out" | grep -q 'Worktree Feature_Work-1' \ # ── Branch switch in the same worktree: identity stable, label follows ─────── git -C "$wt" checkout -q -b "another/branch-name" "$wt/scripts/mobile-worktree-overrides.sh" > /dev/null -grep -q '^BUNDLE_IDENTIFIER = 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" @@ -156,6 +156,9 @@ gradle="$repo_root/mobile/android/app/build.gradle.kts" manifest="$repo_root/mobile/android/app/src/main/AndroidManifest.xml" plist="$repo_root/mobile/ios/Runner/Info.plist" +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile$' "$debug_xcconfig" \ + && pass "Debug.xcconfig defaults to the dogfood bundle identifier" \ + || fail "Debug.xcconfig must default to xyz.block.buzz.dogfood.mobile" grep -q 'WorktreeOverrides.xcconfig' "$debug_xcconfig" \ && pass "Debug.xcconfig includes WorktreeOverrides" \ || fail "Debug.xcconfig must include WorktreeOverrides.xcconfig" @@ -166,15 +169,20 @@ 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" + grep -q '$(APP_DISPLAY_NAME)' "$plist" \ && pass "Info.plist display name resolves from build settings" \ || fail "Info.plist CFBundleDisplayName must be \$(APP_DISPLAY_NAME)"