feat(mobile-infra): support development App Attest (#7744)

Development-signed iOS apps cannot enroll with the current gateway
because their Apple App Attest AAGUID is rejected.

Add an explicit personal development build feature and environment
setting so a development gateway can use a developer's own application
identity and sandbox APNs credentials.

Ordinary gateway builds remain production-only. Special builds also
default to production, enforce exactly the selected signed AAGUID, and
retain certificate-chain, pinned-root, nonce, app-ID, key,
credential-ID, and counter verification.

Document the matching mobile signing configuration and expose the
feature through an optional Docker build argument.

### Validation

The development image deployed successfully to a personal Railway stack;
private readiness, enrollment challenges, authenticated relay access,
and APNs sandbox TLS/HTTP2 connectivity passed.

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-09-22 15:53:46 -07:00
committed by GitHub
parent 26ede6dfa2
commit 7e96f91229
12 changed files with 742 additions and 24 deletions

View File

@@ -569,6 +569,39 @@ jobs:
if-no-files-found: error
retention-days: 1
push-gateway-personal-dev:
name: Build personal development push gateway
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Build personal development image without publishing
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile.push-gateway
platforms: linux/amd64
build-args: BUZZ_PUSH_CARGO_FEATURES=personal-dev-app-attest
load: true
tags: buzz-push-gateway:personal-dev-test
cache-from: type=gha,scope=push-gateway-personal-dev
cache-to: type=gha,scope=push-gateway-personal-dev,mode=max
- name: Verify runtime dependencies and startup
run: |
set +e
output=$(docker run --rm buzz-push-gateway:personal-dev-test 2>&1)
status=$?
set -e
test "$status" -eq 1
[[ "$output" == *'Missing("BUZZ_PUSH_GRANT_KEYS")'* ]]
push-gateway-merge:
name: Publish public push gateway image
if: github.event_name != 'pull_request'

223
Cargo.lock generated
View File

@@ -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]]

View File

@@ -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

View File

@@ -400,6 +400,7 @@ test-unit:
# Gateway unit and black-box HTTP tests are infra-free. Postgres-backed
# contract/race tests run in the dedicated CI job below.
cargo nextest run -p buzz-push-gateway
cargo nextest run -p buzz-push-gateway --features personal-dev-app-attest
# Kubernetes backend provider: the decision layers (state machine, GC
# planner, env precedence, naming, wire) are pure functions with a fake
# substrate, so they belong in the unit job. Enumerated explicitly

View File

@@ -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 }

View File

@@ -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<u8>,
environment: AppAttestEnvironment,
}
impl AppAttestVerifier {
/// Construct a production-only verifier.
pub fn new(app_id: String, apple_root_cert_pem: Vec<u8>) -> Result<Self, AppAttestError> {
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<u8>,
environment: AppAttestEnvironment,
) -> Result<Self, AppAttestError> {
if app_id.is_empty()
|| Sha256::digest(&apple_root_cert_pem).as_slice() != APPLE_APP_ATTEST_ROOT_PEM_SHA256
{
@@ -42,8 +59,14 @@ impl AppAttestVerifier {
Ok(Self {
app_id,
apple_root_cert_pem,
environment,
})
}
/// Exact server-selected environment accepted by this verifier.
pub fn environment(&self) -> AppAttestEnvironment {
self.environment
}
/// `client_data` is the exact canonical enrollment transcript represented by
/// the challenge string passed to `attestKey`; callers must include every
/// authority-bearing enrollment field in it.
@@ -59,6 +82,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 +136,83 @@ 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)?;
if count != 3 {
return Err(AppAttestError::Invalid);
}
let mut auth_data = None;
let mut format_seen = false;
let mut statement_seen = false;
for _ in 0..count {
match decoder.str().map_err(|_| AppAttestError::Invalid)? {
"authData" if auth_data.is_none() => {
auth_data = Some(decoder.bytes().map_err(|_| AppAttestError::Invalid)?);
}
"fmt" if !format_seen => {
if decoder.str().map_err(|_| AppAttestError::Invalid)? != "apple-appattest" {
return Err(AppAttestError::Invalid);
}
format_seen = true;
}
"attStmt" if !statement_seen => {
// appattest 0.1.1 treats indefinite containers as empty without
// consuming their contents. Accept only the definite schema so
// its cursor cannot reinterpret nested fields as root fields.
if decoder.map().map_err(|_| AppAttestError::Invalid)? != Some(2) {
return Err(AppAttestError::Invalid);
}
let mut certs_seen = false;
let mut receipt_seen = false;
for _ in 0..2 {
match decoder.str().map_err(|_| AppAttestError::Invalid)? {
"x5c" if !certs_seen => {
let count = decoder
.array()
.map_err(|_| AppAttestError::Invalid)?
.filter(|count| (1..=3).contains(count))
.ok_or(AppAttestError::Invalid)?;
for _ in 0..count {
decoder.bytes().map_err(|_| AppAttestError::Invalid)?;
}
certs_seen = true;
}
"receipt" if !receipt_seen => {
decoder.bytes().map_err(|_| AppAttestError::Invalid)?;
receipt_seen = true;
}
_ => return Err(AppAttestError::Invalid),
}
}
statement_seen = true;
}
_ => return 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 +274,7 @@ mod tests {
AppAttestVerifier {
app_id: app_id.to_owned(),
apple_root_cert_pem: root_cert_pem.to_vec(),
environment: AppAttestEnvironment::Production,
}
}
@@ -288,6 +390,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 +410,194 @@ 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());
}
#[cfg(feature = "personal-dev-app-attest")]
#[test]
fn nested_statement_cannot_bypass_environment_fence() {
for (json, environment, decoy_aaguid) in [
(
WRONG_AAGUID_FIXTURE_JSON,
AppAttestEnvironment::Production,
&b"appattest\0\0\0\0\0\0\0"[..],
),
(
GOOD_FIXTURE_JSON,
AppAttestEnvironment::Development,
&b"appattestdevelop"[..],
),
] {
let fixture = fixture(json);
let original = STANDARD.decode(&fixture.attestation_b64).unwrap();
let mut decoder = minicbor::Decoder::new(&original);
let count = decoder.map().unwrap().unwrap();
let mut auth = &[][..];
let mut statement = &[][..];
for _ in 0..count {
let key = decoder.str().unwrap();
let start = decoder.position();
decoder.skip().unwrap();
match key {
"authData" => auth = &original[start..decoder.position()],
"attStmt" => statement = &original[start..decoder.position()],
_ => {}
}
}
let mut decoy = [0u8; 53];
decoy[37..53].copy_from_slice(decoy_aaguid);
let mut buffer = vec![0; original.len() + 256];
let mut encoder =
minicbor::Encoder::new(minicbor::encode::write::Cursor::new(buffer.as_mut_slice()));
encoder
.map(5)
.unwrap()
.str("fmt")
.unwrap()
.str("apple-appattest")
.unwrap()
.str("authData")
.unwrap()
.bytes(&decoy)
.unwrap()
.str("attStmt")
.unwrap()
.begin_map()
.unwrap()
.str("attStmt")
.unwrap();
use minicbor::encode::Write;
encoder.writer_mut().write_all(statement).unwrap();
encoder.str("authData").unwrap();
encoder.writer_mut().write_all(auth).unwrap();
encoder
.end()
.unwrap()
.str("padding1")
.unwrap()
.u8(0)
.unwrap()
.str("padding2")
.unwrap()
.u8(0)
.unwrap();
let length = encoder.writer().position();
let malicious = &buffer[..length];
// Prove this envelope reaches valid signed material in the dependency.
Attestation::from_cbor_bytes(malicious)
.unwrap()
.verify(
&fixture.challenge,
&fixture.app_id,
&fixture.key_id_b64,
fixture.root_cert_pem.as_bytes(),
)
.unwrap();
let mut verifier = verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes());
verifier.environment = environment;
assert!(
verifier
.verify_attestation(
&STANDARD.encode(malicious),
&fixture.key_id_b64,
fixture.challenge.as_bytes(),
)
.is_err(),
"cross-environment nested statement accepted: {environment:?}"
);
}
}
#[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);

View File

@@ -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<String, String>) -> Result<AppProfileConfig, Config
.ok_or(ConfigError::Missing(key))
};
let app_attest_app_id = required(app_id_key)?.to_owned();
let app_attest_environment = match e
.get("BUZZ_PUSH_APP_ATTEST_ENVIRONMENT")
.map(String::as_str)
{
None | Some("production") => 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<String, String>) -> Result<AppProfileConfig, Config
};
Ok(AppProfileConfig {
app_attest_app_id,
app_attest_environment,
apns_cert_path,
apns_topic,
apns_environment,
@@ -258,6 +280,47 @@ impl Config {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attestation_environment_is_explicit_and_build_gated() {
let mut env = base();
assert_eq!(
Config::from_map(&env)
.unwrap()
.profile
.app_attest_environment,
AppAttestEnvironment::Production
);
for value in ["", "sandbox", "Production", "unknown"] {
env.insert("BUZZ_PUSH_APP_ATTEST_ENVIRONMENT".into(), value.into());
assert!(Config::from_map(&env).is_err());
}
env.insert(
"BUZZ_PUSH_APP_ATTEST_ENVIRONMENT".into(),
"production".into(),
);
assert_eq!(
Config::from_map(&env)
.unwrap()
.profile
.app_attest_environment,
AppAttestEnvironment::Production
);
env.insert(
"BUZZ_PUSH_APP_ATTEST_ENVIRONMENT".into(),
"development".into(),
);
#[cfg(feature = "personal-dev-app-attest")]
assert_eq!(
Config::from_map(&env)
.unwrap()
.profile
.app_attest_environment,
AppAttestEnvironment::Development
);
#[cfg(not(feature = "personal-dev-app-attest"))]
assert!(Config::from_map(&env).is_err());
}
fn base() -> HashMap<String, String> {
HashMap::from([
(

View File

@@ -38,15 +38,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app_attest_root = fs::read(&c.app_attest_root_cert_path)?;
let configured = &c.profile;
let profile = {
let apple = AppAttestVerifier::with_environment(
configured.app_attest_app_id.clone(),
app_attest_root,
configured.app_attest_environment,
)?;
tracing::info!(environment = ?apple.environment(), "App Attest verifier configured");
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,

View File

@@ -0,0 +1,86 @@
//! Exercise the actual executable's parsed configuration and verifier constructor.
use base64::{engine::general_purpose::STANDARD, Engine as _};
use std::{
process::{Command, Stdio},
time::{Duration, Instant},
};
#[test]
fn parsed_configuration_reaches_executable_verifier() {
for setting in [None, Some("production"), Some("development")] {
let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-push-gateway"));
command.env_clear().envs([
(
"BUZZ_PUSH_GRANT_KEYS",
format!("grant:{}", STANDARD.encode([1; 32])),
),
(
"BUZZ_PUSH_TOKEN_KEYS",
format!("token:{}", STANDARD.encode([2; 32])),
),
("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example".into()),
("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "2592000".into()),
("DATABASE_URL", "unused".into()),
(
"BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID",
"TEAM.test.app".into(),
),
(
"BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH",
format!(
"{}/tests/fixtures/apple-app-attestation-root.pem",
env!("CARGO_MANIFEST_DIR")
),
),
// Startup stops immediately after configuring the real verifier.
("BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", "".into()),
("BUZZ_PUSH_DOGFOOD_APNS_TOPIC", "test.app".into()),
("RUST_LOG", "info".into()),
]);
// A directory cannot be read as an APNs certificate, on every platform.
command.env(
"BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH",
env!("CARGO_MANIFEST_DIR"),
);
if let Some(setting) = setting {
command.env("BUZZ_PUSH_APP_ATTEST_ENVIRONMENT", setting);
}
let mut child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while child.try_wait().unwrap().is_none() {
if Instant::now() >= deadline {
child.kill().unwrap();
child.wait().unwrap();
panic!("gateway startup did not terminate");
}
std::thread::sleep(Duration::from_millis(10));
}
let output = child.wait_with_output().unwrap();
assert!(!output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
if setting == Some("development") && !cfg!(feature = "personal-dev-app-attest") {
assert!(
stderr.contains("BUZZ_PUSH_APP_ATTEST_ENVIRONMENT"),
"{stderr}"
);
assert!(!stdout.contains("App Attest verifier configured"));
} else {
let event = stdout
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.find(|event| event["fields"]["message"] == "App Attest verifier configured")
.unwrap_or_else(|| panic!("verifier was not constructed: {stdout} {stderr}"));
let expected = if setting == Some("development") {
"Development"
} else {
"Production"
};
assert_eq!(event["fields"]["environment"], expected);
}
}
}

View File

@@ -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.

View File

@@ -140,20 +140,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

View File

@@ -120,6 +120,8 @@ run_unit_tests() {
run_test_step "buzz-push-gateway tests" \
cargo test -p buzz-push-gateway -- --nocapture
run_test_step "buzz-push-gateway personal development tests" \
cargo test -p buzz-push-gateway --features personal-dev-app-attest -- --nocapture
# Kubernetes backend provider: pure decision layers driven by a fake
# substrate, no cluster. Mirrors the nextest path in `just test-unit` —