feat(push): add gated development App Attest bypass

Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
2026-07-31 09:47:28 -07:00
committed by npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6
parent 0e3389a037
commit 5d236db269
10 changed files with 452 additions and 18 deletions

View File

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

View File

@@ -183,6 +183,7 @@ impl PushTransport for ApnsTransport {
profile: AppProfile,
endpoint: &str,
) -> DeliveryOutcome {
crate::metrics::record_apns_send_attempt();
let response = self.send_response(attempt, profile, endpoint).await;
let response = match response {
Ok(response) => response,

View File

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

View File

@@ -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
@@ -150,19 +152,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,
@@ -172,6 +193,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_cert_path: req(e, "BUZZ_PUSH_APNS_CERT_PATH")?.into(),
@@ -225,6 +248,8 @@ mod tests {
),
("BUZZ_PUSH_APNS_CERT_PATH".into(), "/identity.pem".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()),
])
}
@@ -288,6 +313,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 [

View File

@@ -0,0 +1,168 @@
//! Explicit development-only App Attest sentinel verification.
//!
//! This module is absent unless the non-default `dev-app-attest-bypass` Cargo
//! feature is enabled. Runtime configuration adds a second, independent gate.
use crate::app_attest::{AppAttestError, VerifiedAssertion, VerifiedAttestation};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use sha2::{Digest, Sha256};
const ATTESTATION_PREFIX: &[u8] = b"buzz-dev-app-attest-v1:";
const ASSERTION_SENTINEL: &[u8] = b"buzz-dev-app-assertion-v1";
const PUBLIC_KEY_PREFIX: &[u8] = b"buzz-dev-app-attest-public-key-v1:";
const NONCE_BYTES: usize = 32;
pub fn assertion_sentinel() -> String {
STANDARD.encode(ASSERTION_SENTINEL)
}
pub fn verify_attestation(
attestation_b64: &str,
key_id_b64: &str,
client_data: &[u8],
) -> Result<VerifiedAttestation, AppAttestError> {
let attestation = STANDARD
.decode(attestation_b64)
.map_err(|_| AppAttestError::Invalid)?;
let supplied_key_id = STANDARD
.decode(key_id_b64)
.map_err(|_| AppAttestError::Invalid)?;
let expected_key_id = Sha256::digest(&attestation);
if !attestation.starts_with(ATTESTATION_PREFIX)
|| attestation.len() != ATTESTATION_PREFIX.len() + NONCE_BYTES
|| supplied_key_id.as_slice() != expected_key_id.as_slice()
|| client_data.is_empty()
{
return Err(AppAttestError::Invalid);
}
let mut public_key = PUBLIC_KEY_PREFIX.to_vec();
public_key.extend_from_slice(&expected_key_id);
Ok(VerifiedAttestation {
key_id: expected_key_id.to_vec(),
public_key,
})
}
pub fn verify_assertion(
assertion_b64: &str,
client_data: &[u8],
public_key: &[u8],
previous_counter: u32,
challenge: &str,
stored_challenge: &str,
) -> Result<VerifiedAssertion, AppAttestError> {
let assertion = STANDARD
.decode(assertion_b64)
.map_err(|_| AppAttestError::Invalid)?;
if assertion != ASSERTION_SENTINEL
|| client_data.is_empty()
|| !public_key.starts_with(PUBLIC_KEY_PREFIX)
|| public_key.len() != PUBLIC_KEY_PREFIX.len() + 32
|| challenge.is_empty()
|| challenge != stored_challenge
{
return Err(AppAttestError::Invalid);
}
Ok(VerifiedAssertion {
counter: previous_counter
.checked_add(1)
.ok_or(AppAttestError::Invalid)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn attestation(nonce: u8) -> (String, String) {
let mut sentinel = ATTESTATION_PREFIX.to_vec();
sentinel.extend_from_slice(&[nonce; NONCE_BYTES]);
let key_id = Sha256::digest(&sentinel);
(STANDARD.encode(sentinel), STANDARD.encode(key_id))
}
#[test]
fn versioned_attestation_derives_a_unique_bound_key_id() {
let (attestation_a, key_id_a) = attestation(1);
let verified_a = verify_attestation(
&attestation_a,
&key_id_a,
b"canonical enrollment transcript",
)
.unwrap();
assert_eq!(verified_a.key_id.len(), 32);
assert!(verified_a.public_key.starts_with(PUBLIC_KEY_PREFIX));
let (attestation_b, key_id_b) = attestation(2);
let verified_b = verify_attestation(
&attestation_b,
&key_id_b,
b"canonical enrollment transcript",
)
.unwrap();
assert_ne!(verified_a.key_id, verified_b.key_id);
for (attestation, key, transcript) in [
("bad".to_owned(), key_id_a.clone(), b"transcript".as_slice()),
(attestation_a.clone(), key_id_b, b"transcript"),
(attestation_a, key_id_a, b""),
] {
assert!(verify_attestation(&attestation, &key, transcript).is_err());
}
}
#[test]
fn bad_attestation_sentinel_is_rejected_before_good_sentinel_is_accepted() {
let (good_attestation, key_id) = attestation(1);
let bad_attestation = STANDARD.encode(b"buzz-dev-app-attest-v1:bad");
assert!(verify_attestation(&bad_attestation, &key_id, b"transcript").is_err());
assert!(verify_attestation(&good_attestation, &key_id, b"transcript").is_ok());
}
#[test]
fn exact_assertion_sentinel_and_stored_dev_marker_advance_counter() {
let (attestation, key_id) = attestation(1);
let public_key = verify_attestation(&attestation, &key_id, b"transcript")
.unwrap()
.public_key;
let verified = verify_assertion(
&assertion_sentinel(),
b"canonical assertion transcript",
&public_key,
7,
"challenge",
"challenge",
)
.unwrap();
assert_eq!(verified.counter, 8);
assert!(verify_assertion(
"bad",
b"canonical assertion transcript",
&public_key,
7,
"challenge",
"challenge",
)
.is_err());
assert!(verify_assertion(
&assertion_sentinel(),
b"canonical assertion transcript",
b"not-the-development-marker",
7,
"challenge",
"challenge",
)
.is_err());
assert!(verify_assertion(
&assertion_sentinel(),
b"canonical assertion transcript",
&public_key,
u32::MAX,
"challenge",
"challenge",
)
.is_err());
}
}

View File

@@ -1,7 +1,7 @@
//! Stateful installation, delegation, delivery, and health APIs.
use crate::{
apns::{DeliveryAttempt, DeliveryOutcome, PushTransport},
app_attest::AppAttestVerifier,
app_attest_policy::AppAttestPolicy,
authority::{
AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation,
},
@@ -36,7 +36,7 @@ use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer};
#[derive(Clone)]
pub struct AppState {
pub grant_keyring: Arc<GrantKeyring>,
pub app_attest: Arc<AppAttestVerifier>,
pub app_attest: Arc<AppAttestPolicy>,
pub authority: Arc<dyn AuthorityStore>,
pub token_keyring: Arc<TokenKeyring>,
pub transport: Arc<dyn PushTransport>,

View File

@@ -1,8 +1,11 @@
//! Stateful, capability-gated APNs last hop for NIP-PL.
pub mod apns;
pub mod app_attest;
pub mod app_attest_policy;
pub mod authority;
pub mod config;
#[cfg(feature = "dev-app-attest-bypass")]
pub mod dev_app_attest;
pub mod grant;
pub mod http;
pub mod metrics;

View File

@@ -1,6 +1,7 @@
use buzz_push_gateway::{
apns::ApnsTransport,
app_attest::AppAttestVerifier,
app_attest_policy::AppAttestPolicy,
authority::AuthorityStore,
config::Config,
grant::{GrantKey, GrantKeyring},
@@ -75,10 +76,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
});
let app_attest = Arc::new(AppAttestVerifier::new(
c.app_attest_app_id,
fs::read(&c.app_attest_root_cert_path)?,
)?);
let apple_app_attest =
AppAttestVerifier::new(c.app_attest_app_id, fs::read(&c.app_attest_root_cert_path)?)?;
#[cfg(feature = "dev-app-attest-bypass")]
let app_attest = if c.dev_app_attest_bypass {
tracing::warn!(
"DEVELOPMENT APP ATTEST BYPASS ACTIVE; Apple attestation and assertion verification are disabled"
);
Arc::new(AppAttestPolicy::development())
} else {
Arc::new(AppAttestPolicy::apple(apple_app_attest))
};
#[cfg(not(feature = "dev-app-attest-bypass"))]
let app_attest = Arc::new(AppAttestPolicy::apple(apple_app_attest));
let accepting = Arc::new(AtomicBool::new(true));
let (public, health) = router_with_metrics(
AppState {

View File

@@ -52,6 +52,13 @@ fn outcome_label(outcome: DeliveryOutcome) -> &'static str {
}
}
/// 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))
@@ -152,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 {
@@ -172,6 +180,7 @@ 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_admissions_total",

View File

@@ -103,6 +103,9 @@ run_unit_tests() {
run_test_step "buzz-push-gateway tests" \
cargo test -p buzz-push-gateway -- --nocapture
run_test_step "buzz-push-gateway dev App Attest bypass tests" \
cargo test -p buzz-push-gateway --features dev-app-attest-bypass -- --nocapture
}
# ---- DB / integration tests (infra required) --------------------------------