Skip to content
Draft
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
4 changes: 4 additions & 0 deletions crates/buzz-push-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ rust-version.workspace = true
license.workspace = true
repository.workspace = true

[features]
default = []
dev-app-attest-bypass = []

[lib]
name = "buzz_push_gateway"
path = "src/lib.rs"
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-push-gateway/src/apns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ impl PushTransport for ApnsTransport {
AppProfile::BuzzIosProduction => &self.production_base_url,
AppProfile::BuzzIosSandbox => &self.sandbox_base_url,
};
crate::metrics::record_apns_send_attempt();
let response = self
.client
.post(format!("{base_url}/3/device/{endpoint}"))
Expand Down
82 changes: 82 additions & 0 deletions crates/buzz-push-gateway/src/app_attest_policy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//! Selects the production Apple verifier or the feature-gated development stub.

use crate::app_attest::{
AppAttestError, AppAttestVerifier, VerifiedAssertion, VerifiedAttestation,
};

#[cfg_attr(
not(feature = "dev-app-attest-bypass"),
doc = r#"
The development policy is structurally unavailable in default builds:

```compile_fail
use buzz_push_gateway::app_attest_policy::AppAttestPolicy;

let _ = AppAttestPolicy::Development;
```
"#
)]
#[derive(Clone)]
pub enum AppAttestPolicy {
Apple(AppAttestVerifier),
#[cfg(feature = "dev-app-attest-bypass")]
Development,
}

impl AppAttestPolicy {
pub fn apple(verifier: AppAttestVerifier) -> Self {
Self::Apple(verifier)
}

#[cfg(feature = "dev-app-attest-bypass")]
pub fn development() -> Self {
Self::Development
}

pub fn verify_attestation(
&self,
attestation_b64: &str,
key_id_b64: &str,
client_data: &[u8],
) -> Result<VerifiedAttestation, AppAttestError> {
match self {
Self::Apple(verifier) => {
verifier.verify_attestation(attestation_b64, key_id_b64, client_data)
}
#[cfg(feature = "dev-app-attest-bypass")]
Self::Development => {
crate::dev_app_attest::verify_attestation(attestation_b64, key_id_b64, client_data)
}
}
}

pub fn verify_assertion(
&self,
assertion_b64: &str,
client_data: &[u8],
public_key: &[u8],
previous_counter: u32,
challenge: &str,
stored_challenge: &str,
) -> Result<VerifiedAssertion, AppAttestError> {
match self {
Self::Apple(verifier) => verifier.verify_assertion(
assertion_b64,
client_data,
public_key,
previous_counter,
challenge,
stored_challenge,
),
#[cfg(feature = "dev-app-attest-bypass")]
Self::Development => crate::dev_app_attest::verify_assertion(
assertion_b64,
client_data,
public_key,
previous_counter,
challenge,
stored_challenge,
),
}
}
}
180 changes: 167 additions & 13 deletions crates/buzz-push-gateway/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ pub struct Config {
pub database_url: String,
pub app_attest_app_id: String,
pub app_attest_root_cert_path: PathBuf,
#[cfg(feature = "dev-app-attest-bypass")]
pub dev_app_attest_bypass: bool,
/// Ordered current key first, followed by decrypt-only predecessors.
pub grant_keys: Vec<KeyConfig>,
/// Independent token-custody keyring. These keys MUST NOT be reused for
Expand Down Expand Up @@ -152,19 +154,38 @@ impl Config {
if enabled_profiles.is_empty() {
return Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES"));
}
let bind_addr = e
.get("BUZZ_PUSH_BIND_ADDR")
.map(String::as_str)
.unwrap_or("0.0.0.0:8080")
.parse::<SocketAddr>()
.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::<SocketAddr>()
.map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?;
#[cfg(feature = "dev-app-attest-bypass")]
let dev_app_attest_bypass = e
.get("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS")
.is_some_and(|value| value == "true");
#[cfg(feature = "dev-app-attest-bypass")]
if dev_app_attest_bypass
&& (!bind_addr.ip().is_loopback() || !health_addr.ip().is_loopback())
{
return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS"));
}
#[cfg(feature = "dev-app-attest-bypass")]
if dev_app_attest_bypass
&& (enabled_profiles.len() != 1
|| !enabled_profiles.contains(&crate::model::AppProfile::BuzzIosSandbox))
{
return Err(ConfigError::Invalid("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS"));
}
Ok(Self {
bind_addr: e
.get("BUZZ_PUSH_BIND_ADDR")
.map(String::as_str)
.unwrap_or("0.0.0.0:8080")
.parse()
.map_err(|_| ConfigError::Invalid("BUZZ_PUSH_BIND_ADDR"))?,
health_addr: e
.get("BUZZ_PUSH_HEALTH_ADDR")
.map(String::as_str)
.unwrap_or("0.0.0.0:8081")
.parse()
.map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?,
bind_addr,
health_addr,
public_delivery_url,
max_grant_lifetime_seconds,
max_installation_lifetime_seconds,
Expand All @@ -174,6 +195,8 @@ impl Config {
database_url: req(e, "DATABASE_URL")?.to_owned(),
app_attest_app_id: req(e, "BUZZ_PUSH_APP_ATTEST_APP_ID")?.to_owned(),
app_attest_root_cert_path: req(e, "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH")?.into(),
#[cfg(feature = "dev-app-attest-bypass")]
dev_app_attest_bypass,
grant_keys,
token_keys,
apns_key_path: req(e, "BUZZ_PUSH_APNS_KEY_PATH")?.into(),
Expand Down Expand Up @@ -220,7 +243,7 @@ mod tests {
),
(
"DATABASE_URL".into(),
"postgres://buzz:test@localhost/buzz".into(),
"postgres://buzz:test@localhost/buzz".into(), // sadscan:disable np.postgres.1 -- existing local test-only credentials
),
("BUZZ_PUSH_APP_ATTEST_APP_ID".into(), "TEAM.app".into()),
(
Expand All @@ -231,6 +254,8 @@ mod tests {
("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_BIND_ADDR".into(), "127.0.0.1:8080".into()),
("BUZZ_PUSH_HEALTH_ADDR".into(), "127.0.0.1:8081".into()),
])
}

Expand Down Expand Up @@ -279,6 +304,135 @@ 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());
}

#[cfg(feature = "dev-app-attest-bypass")]
#[test]
fn dev_app_attest_bypass_is_off_when_absent_or_exactly_false() {
let absent = Config::from_map(&base()).unwrap();
assert!(!absent.dev_app_attest_bypass);

let mut explicit_false = base();
explicit_false.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "false".into());
let explicit_false = Config::from_map(&explicit_false).unwrap();
assert!(!explicit_false.dev_app_attest_bypass);
}

#[cfg(feature = "dev-app-attest-bypass")]
#[test]
fn dev_app_attest_bypass_selects_apple_for_every_value_other_than_exact_true() {
for value in [
None,
Some(""),
Some("false"),
Some("TRUE"),
Some("1"),
Some("yes"),
Some(" true"),
] {
let mut env = base();
if let Some(value) = value {
env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), value.into());
}
let config = Config::from_map(&env).unwrap();
assert!(!config.dev_app_attest_bypass, "enabled for {value:?}");
}
}

#[cfg(feature = "dev-app-attest-bypass")]
#[test]
fn dev_app_attest_bypass_requires_both_loopback_listeners() {
for (key, value) in [
("BUZZ_PUSH_BIND_ADDR", "0.0.0.0:8080"),
("BUZZ_PUSH_HEALTH_ADDR", "[::]:8081"),
] {
let mut env = base();
env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "true".into());
env.insert(
"BUZZ_PUSH_ENABLED_PROFILES".into(),
"buzz-ios-sandbox".into(),
);
env.insert(key.into(), value.into());
assert!(Config::from_map(&env).is_err(), "accepted {key}={value}");
}
}

#[cfg(feature = "dev-app-attest-bypass")]
#[test]
fn non_loopback_bind_is_rejected_before_loopback_equivalent_is_accepted() {
let mut non_loopback = base();
non_loopback.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "true".into());
non_loopback.insert(
"BUZZ_PUSH_ENABLED_PROFILES".into(),
"buzz-ios-sandbox".into(),
);
non_loopback.insert("BUZZ_PUSH_BIND_ADDR".into(), "0.0.0.0:8080".into());
assert!(Config::from_map(&non_loopback).is_err());

non_loopback.insert("BUZZ_PUSH_BIND_ADDR".into(), "127.0.0.1:8080".into());
assert!(
Config::from_map(&non_loopback)
.unwrap()
.dev_app_attest_bypass
);
}

#[cfg(feature = "dev-app-attest-bypass")]
#[test]
fn dev_app_attest_bypass_requires_sandbox_as_the_only_profile() {
for profiles in [
"buzz-ios-production",
"buzz-ios-sandbox,buzz-ios-production",
] {
let mut env = base();
env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "true".into());
env.insert("BUZZ_PUSH_ENABLED_PROFILES".into(), profiles.into());
assert!(
Config::from_map(&env).is_err(),
"accepted profiles {profiles}"
);
}
}

#[cfg(feature = "dev-app-attest-bypass")]
#[test]
fn dev_app_attest_bypass_accepts_explicit_true_for_loopback_sandbox() {
let mut env = base();
env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "true".into());
env.insert(
"BUZZ_PUSH_ENABLED_PROFILES".into(),
"buzz-ios-sandbox".into(),
);
assert!(Config::from_map(&env).unwrap().dev_app_attest_bypass);
}

#[cfg(feature = "dev-app-attest-bypass")]
#[test]
fn second_profile_is_rejected_before_sandbox_only_is_accepted() {
let mut env = base();
env.insert("BUZZ_PUSH_DEV_APP_ATTEST_BYPASS".into(), "true".into());
env.insert(
"BUZZ_PUSH_ENABLED_PROFILES".into(),
"buzz-ios-sandbox,buzz-ios-production".into(),
);
assert!(Config::from_map(&env).is_err());

env.insert(
"BUZZ_PUSH_ENABLED_PROFILES".into(),
"buzz-ios-sandbox".into(),
);
assert!(Config::from_map(&env).unwrap().dev_app_attest_bypass);
}

#[test]
fn malformed_or_empty_keyrings_fail_startup() {
for (variable, value) in [
Expand Down
Loading
Loading