Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 215 additions & 8 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions Dockerfile.push-gateway
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions crates/buzz-push-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Comment on lines +18 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the opt-in App Attest feature in CI

All standard gateway test lanes invoke the crate without features (scripts/run-tests.sh:118-119 and Justfile:399-401), while the Docker workflow also builds only the default image. Consequently the new Development variant, its appattest/testing dependency graph, and every #[cfg(feature = "personal-dev-app-attest")] test are skipped in CI, allowing the documented personal-development image to stop compiling or validating attestations without failing a gate; add a feature-enabled check/test lane.

AGENTS.md reference: AGENTS.md:L188-L192

Useful? React with 👍 / 👎.


[dependencies]
aes-gcm = "0.10"
appattest = { version = "0.1.1", default-features = false }
Expand Down
154 changes: 153 additions & 1 deletion crates/buzz-push-gateway/src/app_attest.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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
{
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -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;
Comment on lines +373 to +374

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exercise development mode through the production seam

The development-mode test constructs the production-only test helper and then mutates its private environment field, so it never exercises AppAttestVerifier::with_environment or the Configmain → verifier wiring. For example, changing main.rs back to AppAttestVerifier::new would make every configured development gateway reject enrollment while this test remains green; construct the verifier through the production API, ideally from parsed configuration, so the regression is falsifiable.

AGENTS.md reference: AGENTS.md:L188-L192

Useful? React with 👍 / 👎.

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);
Expand Down
63 changes: 63 additions & 0 deletions crates/buzz-push-gateway/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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([
(
Expand Down
3 changes: 2 additions & 1 deletion crates/buzz-push-gateway/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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),
Expand Down
26 changes: 26 additions & 0 deletions docs/push-gateway-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading