diff --git a/Cargo.lock b/Cargo.lock index 09d8faa9887..421642e526d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,7 +147,10 @@ dependencies = [ "aws-lc-rs", "base64 0.22.1", "byteorder", + "ciborium", "minicbor", + "openssl", + "p256", "rustls-pki-types", "rustls-webpki", ] @@ -603,6 +606,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base16ct" version = "1.0.0" @@ -1681,6 +1690,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1857,6 +1893,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -2124,6 +2166,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -2439,14 +2493,25 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + [[package]] name = "der" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -2561,6 +2626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -2572,7 +2638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -2668,13 +2734,27 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ed25519" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", + "pkcs8 0.11.0", "serdect", "signature 3.0.0", ] @@ -2704,6 +2784,26 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -2918,6 +3018,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3352,6 +3462,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.16" @@ -3371,6 +3492,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if 1.0.4", + "crunchy", + "zerocopy", +] + [[package]] name = "hash32" version = "0.3.1" @@ -6612,6 +6744,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "palette" version = "0.7.6" @@ -6724,6 +6868,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -6901,14 +7054,24 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.2", + "spki 0.8.0", ] [[package]] @@ -7096,6 +7259,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -8012,6 +8184,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -8405,6 +8587,20 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -8690,7 +8886,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ - "base16ct", + "base16ct 1.0.0", "serde", ] @@ -8829,6 +9025,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] @@ -9088,6 +9285,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -9095,7 +9302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.2", ] [[package]] diff --git a/Dockerfile.push-gateway b/Dockerfile.push-gateway index 202262d222f..5b0aca8bd3e 100644 --- a/Dockerfile.push-gateway +++ b/Dockerfile.push-gateway @@ -11,13 +11,14 @@ COPY . . RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder +ARG BUZZ_PUSH_CARGO_FEATURES="" RUN apt-get update \ && apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=planner /build/recipe.json recipe.json -RUN cargo chef cook --release --recipe-path recipe.json +RUN cargo chef cook --release --recipe-path recipe.json --features "$BUZZ_PUSH_CARGO_FEATURES" COPY . . -RUN cargo build --release --locked -p buzz-push-gateway --bin buzz-push-gateway \ +RUN cargo build --release --locked -p buzz-push-gateway --bin buzz-push-gateway --features "$BUZZ_PUSH_CARGO_FEATURES" \ && strip target/release/buzz-push-gateway FROM debian:${DEBIAN_VERSION}-slim AS runtime diff --git a/crates/buzz-push-gateway/Cargo.toml b/crates/buzz-push-gateway/Cargo.toml index 06376c02dc5..2946d2802a0 100644 --- a/crates/buzz-push-gateway/Cargo.toml +++ b/crates/buzz-push-gateway/Cargo.toml @@ -15,6 +15,10 @@ path = "src/lib.rs" name = "buzz-push-gateway" path = "src/main.rs" +[features] +# Personal device development only. Production builds remain production-only. +personal-dev-app-attest = ["appattest/testing"] + [dependencies] aes-gcm = "0.10" appattest = { version = "0.1.1", default-features = false } diff --git a/crates/buzz-push-gateway/src/app_attest.rs b/crates/buzz-push-gateway/src/app_attest.rs index df655e23d88..1658f1de3d0 100644 --- a/crates/buzz-push-gateway/src/app_attest.rs +++ b/crates/buzz-push-gateway/src/app_attest.rs @@ -1,5 +1,7 @@ //! Narrow App Attest verification boundary. Production enrollment accepts only -//! Apple production AAGUID material; unsupported devices have no bypass path. +//! Apple production AAGUID material by default; personal development requires +//! an explicit build feature and environment. No cryptographic checks are skipped. +use crate::config::AppAttestEnvironment; use appattest::{assertion::Assertion, attestation::Attestation}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use byteorder::{BigEndian, ByteOrder}; @@ -31,9 +33,24 @@ pub enum AppAttestError { pub struct AppAttestVerifier { app_id: String, apple_root_cert_pem: Vec, + environment: AppAttestEnvironment, } impl AppAttestVerifier { + /// Construct a production-only verifier. pub fn new(app_id: String, apple_root_cert_pem: Vec) -> Result { + Self::with_environment( + app_id, + apple_root_cert_pem, + AppAttestEnvironment::Production, + ) + } + + /// Construct a verifier for exactly one server-selected attestation environment. + pub fn with_environment( + app_id: String, + apple_root_cert_pem: Vec, + environment: AppAttestEnvironment, + ) -> Result { if app_id.is_empty() || Sha256::digest(&apple_root_cert_pem).as_slice() != APPLE_APP_ATTEST_ROOT_PEM_SHA256 { @@ -42,6 +59,7 @@ impl AppAttestVerifier { Ok(Self { app_id, apple_root_cert_pem, + environment, }) } /// `client_data` is the exact canonical enrollment transcript represented by @@ -59,6 +77,7 @@ impl AppAttestVerifier { if cbor.is_empty() || cbor.len() > crate::model::MAX_APP_ATTESTATION_BYTES { return Err(AppAttestError::Invalid); } + verify_attestation_environment(&cbor, self.environment)?; let challenge = std::str::from_utf8(client_data).map_err(|_| AppAttestError::Invalid)?; let att = Attestation::from_cbor_bytes(&cbor).map_err(|_| AppAttestError::Invalid)?; let (public_key, _) = att @@ -112,6 +131,44 @@ impl AppAttestVerifier { } } +// The dependency's development feature accepts both Apple environments. Fence +// the exact signed authData here, then let its full verifier validate those same +// bytes (chain, nonce, app ID, counter, public key and credential ID). +fn verify_attestation_environment( + cbor: &[u8], + environment: AppAttestEnvironment, +) -> Result<(), AppAttestError> { + let mut decoder = minicbor::Decoder::new(cbor); + let count = decoder + .map() + .map_err(|_| AppAttestError::Invalid)? + .ok_or(AppAttestError::Invalid)?; + let mut auth_data = None; + for _ in 0..count { + let key = decoder.str().map_err(|_| AppAttestError::Invalid)?; + if key == "authData" { + if auth_data.is_some() { + return Err(AppAttestError::Invalid); + } + auth_data = Some(decoder.bytes().map_err(|_| AppAttestError::Invalid)?); + } else { + decoder.skip().map_err(|_| AppAttestError::Invalid)?; + } + } + if decoder.position() != cbor.len() { + return Err(AppAttestError::Invalid); + } + let expected: &[u8] = match environment { + AppAttestEnvironment::Production => b"appattest\0\0\0\0\0\0\0", + #[cfg(feature = "personal-dev-app-attest")] + AppAttestEnvironment::Development => b"appattestdevelop", + }; + if auth_data.and_then(|data| data.get(37..53)) != Some(expected) { + return Err(AppAttestError::Invalid); + } + Ok(()) +} + /// App Attest assertion CBOR is a closed two-field map. Extracting signCount /// from authenticatorData is safe only after the library verifies the same /// bytes' RP ID, signature, and monotonic relation. @@ -173,6 +230,7 @@ mod tests { AppAttestVerifier { app_id: app_id.to_owned(), apple_root_cert_pem: root_cert_pem.to_vec(), + environment: AppAttestEnvironment::Production, } } @@ -288,6 +346,7 @@ mod tests { fn wrong_aaguid_is_rejected_as_invalid_aaguid() { let fixture = fixture(WRONG_AAGUID_FIXTURE_JSON); assert_eq!(fixture.aaguid, "appattestdevelop"); + #[cfg(not(feature = "personal-dev-app-attest"))] assert_eq!( verify_dependency( &fixture, @@ -307,6 +366,99 @@ mod tests { .is_err()); } + #[cfg(feature = "personal-dev-app-attest")] + #[test] + fn development_mode_preserves_verification_and_rejects_production() { + let dev = fixture(WRONG_AAGUID_FIXTURE_JSON); + let mut v = verifier(&dev.app_id, dev.root_cert_pem.as_bytes()); + v.environment = AppAttestEnvironment::Development; + let cbor = STANDARD.decode(&dev.attestation_b64).unwrap(); + assert!( + verify_attestation_environment(&cbor, v.environment).is_ok(), + "environment parser" + ); + verify_dependency( + &dev, + &dev.app_id, + &dev.challenge, + &dev.key_id_b64, + dev.root_cert_pem.as_bytes(), + ) + .expect("development dependency"); + assert!(v + .verify_attestation( + &dev.attestation_b64, + &dev.key_id_b64, + dev.challenge.as_bytes() + ) + .is_ok()); + let prod = fixture(GOOD_FIXTURE_JSON); + assert!(v + .verify_attestation( + &prod.attestation_b64, + &prod.key_id_b64, + prod.challenge.as_bytes() + ) + .is_err()); + assert!(v + .verify_attestation(&dev.attestation_b64, &dev.key_id_b64, b"wrong challenge") + .is_err()); + assert!(v + .verify_attestation( + &dev.attestation_b64, + &STANDARD.encode([0; 32]), + dev.challenge.as_bytes() + ) + .is_err()); + v.app_id = "OTHER.wrong.app".into(); + assert!(v + .verify_attestation( + &dev.attestation_b64, + &dev.key_id_b64, + dev.challenge.as_bytes() + ) + .is_err()); + v.app_id = dev.app_id; + v.apple_root_cert_pem = fixture(WRONG_ROOT_FIXTURE_JSON).root_cert_pem.into_bytes(); + assert!(v + .verify_attestation( + &dev.attestation_b64, + &dev.key_id_b64, + dev.challenge.as_bytes() + ) + .is_err()); + } + + #[test] + fn environment_parser_rejects_ambiguous_or_truncated_auth_data() { + for data in [vec![], vec![0xa0], vec![0xa1, 0x68], vec![0xbf, 0xff]] { + assert!( + verify_attestation_environment(&data, AppAttestEnvironment::Production).is_err() + ); + } + let mut data = [0u8; 256]; + let mut encoder = + minicbor::Encoder::new(minicbor::encode::write::Cursor::new(data.as_mut_slice())); + let mut auth = [0u8; 53]; + auth[37..53].copy_from_slice(b"appattest\0\0\0\0\0\0\0"); + encoder + .map(2) + .unwrap() + .str("authData") + .unwrap() + .bytes(&auth) + .unwrap() + .str("authData") + .unwrap() + .bytes(&auth) + .unwrap(); + let length = encoder.writer().position(); + assert!( + verify_attestation_environment(&data[..length], AppAttestEnvironment::Production) + .is_err() + ); + } + #[test] fn short_and_oversize_key_ids_are_rejected() { let fixture = fixture(GOOD_FIXTURE_JSON); diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index 1835468fee2..51c2164f69c 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -11,11 +11,23 @@ pub enum ApnsEnvironment { #[derive(Debug, Clone)] pub struct AppProfileConfig { pub app_attest_app_id: String, + /// Exact Apple attestation environment accepted for enrollment. + pub app_attest_environment: AppAttestEnvironment, pub apns_cert_path: PathBuf, pub apns_topic: String, pub apns_environment: ApnsEnvironment, } +/// Apple App Attest environment, independent of the APNs transport environment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppAttestEnvironment { + /// Distributed applications, also the default for personal development builds. + Production, + /// Development-signed applications in an explicitly opted-in gateway build. + #[cfg(feature = "personal-dev-app-attest")] + Development, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct KeyConfig { pub id: String, @@ -143,6 +155,15 @@ fn parse_profile(e: &HashMap) -> Result AppAttestEnvironment::Production, + #[cfg(feature = "personal-dev-app-attest")] + Some("development") => AppAttestEnvironment::Development, + Some(_) => return Err(ConfigError::Invalid("BUZZ_PUSH_APP_ATTEST_ENVIRONMENT")), + }; 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) { @@ -152,6 +173,7 @@ fn parse_profile(e: &HashMap) -> Result HashMap { HashMap::from([ ( diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index f71bf21d6b2..86a76855101 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -43,9 +43,10 @@ async fn main() -> Result<(), Box> { configured.apns_topic.clone(), configured.apns_environment, )?); - let apple = AppAttestVerifier::new( + let apple = AppAttestVerifier::with_environment( configured.app_attest_app_id.clone(), app_attest_root.clone(), + configured.app_attest_environment, )?; buzz_push_gateway::http::ProfileRuntime { app_attest: Arc::new(apple), diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index d3aebe1f7d8..0953ac314de 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -45,6 +45,32 @@ 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. +## Personal device development + +A development-signed iOS app uses Apple's development App Attest environment. +The ordinary gateway binary rejects those attestations. For an isolated personal +stack, build with Cargo feature `personal-dev-app-attest` (Docker build argument +`BUZZ_PUSH_CARGO_FEATURES=personal-dev-app-attest`) and set +`BUZZ_PUSH_APP_ATTEST_ENVIRONMENT=development`. The default remains `production`, +including in that special build; ordinary builds reject the development setting. +The gateway accepts exactly the selected AAGUID and still verifies the pinned +Apple root, certificate chain, nonce, application identity, public key, +credential ID, and counter. This is not a simulator or attestation bypass. + +Set `BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID` to the personal `TEAMID.bundle-id`, +`BUZZ_PUSH_DOGFOOD_APNS_TOPIC` to that same bundle ID, +`BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT=sandbox`, and supply its APNs certificate. +This isolated stack reuses the single `buzz-ios-dogfood` wire profile for its +server-owned personal identity; it does not add a production application profile. +Do not point distributed dogfood clients at this personal gateway. + +Build the mobile client with the personal team and parent bundle ID, its matching +`.NotificationService` extension, development APNs/App Attest entitlements, and +an explicit `BUZZ_PUSH_GATEWAY_URL`. See `mobile/README.md` for gitignored signing +overrides. Both targets need matching provisioning profiles; the parent profile +must include the capabilities in `Runner.entitlements`. Validate enrollment and +notification presentation on a physical device, not a simulator. + ## 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 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. diff --git a/mobile/README.md b/mobile/README.md index e58a6387aa0..a238cd795be 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -137,20 +137,22 @@ 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 +BUNDLE_IDENTIFIER = com.example.buzz.mobile +BUZZ_DEVELOPMENT_TEAM = YOUR_TEAM_ID BUZZ_IOS_PUSH_ENVIRONMENT = development BUZZ_APP_ATTEST_ENVIRONMENT = development BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example ``` -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. +Use your personal bundle ID and team above. Provision both the parent and its +`.NotificationService` extension. Configure an isolated gateway with the matching +App Attest application ID, APNs topic and sandbox certificate. Development +attestation requires the explicit `personal-dev-app-attest` gateway build feature +and `BUZZ_PUSH_APP_ATTEST_ENVIRONMENT=development`; ordinary gateway builds accept +production attestation only. See `docs/push-gateway-deployment.md`. +This validates the personal client/relay/gateway integration, not the internally +distributed dogfood artifact. Validate dogfood separately using the signed +internal release and its production gateway configuration. Parent app identifiers require Apple's Communication Notifications capability and a regenerated app provisioning profile. The