feat(push): support configurable HTTP(S) delivery URLs (#7877)

The relay can use a configurable HTTP(S) gateway delivery URL and signs
the same URL it sends. The gateway verifies NIP-98 against the received
request URL, including authority, port, path and query. App Attest,
scoped grants, replay checks and delivery quotas are unchanged.

Forwarding proxies must preserve Host and supply the original request
scheme through X-Forwarded-Proto. The NIP relay-delivery wording now
defines the configured HTTP(S) URL as the signed URL and labels the
public URL as an example.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-09-25 18:57:49 -07:00
committed by GitHub
parent 02c6309f53
commit 781d39510c
17 changed files with 530 additions and 159 deletions

View File

@@ -75,7 +75,7 @@ RELAY_URL=ws://localhost:3000
# BUZZ_WEB_DIR=./web/dist
# NIP-PL mobile push is an explicit deployment opt-in. A gateway URL alone
# never enables it. When enabled, the exact HTTPS delivery URL is required.
# never enables it. When enabled, the exact HTTP(S) delivery URL is required.
BUZZ_PUSH_ENABLED=false
BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns

View File

@@ -38,8 +38,6 @@ pub struct KeyConfig {
pub struct Config {
pub bind_addr: SocketAddr,
pub health_addr: SocketAddr,
/// External gateway origin, delivery URL, and registered transcript audiences.
pub gateway_urls: GatewayUrls,
pub max_grant_lifetime_seconds: i64,
pub max_installation_lifetime_seconds: i64,
pub endpoint_quota_window_seconds: i64,
@@ -55,53 +53,18 @@ pub struct Config {
pub token_keys: Vec<KeyConfig>,
}
/// Gateway transport URLs and registered NIP-PL v1 transcript audiences.
#[derive(Debug, Clone)]
pub struct GatewayUrls {
/// External HTTPS origin serving the gateway.
pub origin: url::Url,
/// Exact NIP-98 delivery endpoint used by relays.
pub delivery: url::Url,
/// App Attest audience for installation enrollment.
pub enroll_audience: String,
/// App Attest audience for relay delegation.
pub delegate_audience: String,
/// App Attest audience for endpoint rotation.
pub rotate_endpoint_audience: String,
/// App Attest audience for delegation revocation.
pub revoke_delegation_audience: String,
/// App Attest audience for installation revocation.
pub revoke_installation_audience: String,
}
// Registered NIP-PL v1 App Attest audiences are protocol constants, not routing URLs.
/// Fixed NIP-PL v1 App Attest audience for installation enrollment.
pub const ENROLL_AUDIENCE: &str = "https://push.buzz.xyz/v1/installations";
/// Fixed NIP-PL v1 App Attest audience for creating a delegation.
pub const DELEGATE_AUDIENCE: &str = "https://push.buzz.xyz/v1/delegations";
/// Fixed NIP-PL v1 App Attest audience for rotating an installation endpoint.
pub const ROTATE_ENDPOINT_AUDIENCE: &str = "https://push.buzz.xyz/v1/installations/endpoint";
/// Fixed NIP-PL v1 App Attest audience for revoking a delegation.
pub const REVOKE_DELEGATION_AUDIENCE: &str = "https://push.buzz.xyz/v1/delegations/revoke";
/// Fixed NIP-PL v1 App Attest audience for revoking an installation.
pub const REVOKE_INSTALLATION_AUDIENCE: &str = "https://push.buzz.xyz/v1/installations/revoke";
impl GatewayUrls {
pub(crate) fn from_origin(origin: url::Url) -> Result<Self, ConfigError> {
let derive = |path: &str| {
origin
.join(path)
.map_err(|_| ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN"))
};
let delivery = derive("v1/deliveries/apns")?;
// NIP-PL v1 registers these exact audience strings. The configurable
// origin controls transport only; changing transcript bytes requires
// a separately versioned protocol profile.
let enroll_audience = "https://push.buzz.xyz/v1/installations".to_owned();
let delegate_audience = "https://push.buzz.xyz/v1/delegations".to_owned();
let rotate_endpoint_audience = "https://push.buzz.xyz/v1/installations/endpoint".to_owned();
let revoke_delegation_audience = "https://push.buzz.xyz/v1/delegations/revoke".to_owned();
let revoke_installation_audience =
"https://push.buzz.xyz/v1/installations/revoke".to_owned();
Ok(Self {
origin,
delivery,
enroll_audience,
delegate_audience,
rotate_endpoint_audience,
revoke_delegation_audience,
revoke_installation_audience,
})
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("missing required environment variable {0}")]
@@ -203,21 +166,6 @@ impl Config {
}) {
return Err(ConfigError::Invalid("BUZZ_PUSH_TOKEN_KEYS"));
}
let gateway_origin = req(e, "BUZZ_PUSH_GATEWAY_ORIGIN")?
.parse::<url::Url>()
.map_err(|_| ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN"))?;
if gateway_origin.scheme() != "https"
|| gateway_origin.host().is_none()
|| gateway_origin.port().is_some()
|| gateway_origin.path() != "/"
|| gateway_origin.query().is_some()
|| gateway_origin.fragment().is_some()
|| !gateway_origin.username().is_empty()
|| gateway_origin.password().is_some()
{
return Err(ConfigError::Invalid("BUZZ_PUSH_GATEWAY_ORIGIN"));
}
let gateway_urls = GatewayUrls::from_origin(gateway_origin)?;
let max_grant_lifetime_seconds = req(e, "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS")?
.parse::<i64>()
.ok()
@@ -263,7 +211,6 @@ impl Config {
Ok(Self {
bind_addr,
health_addr,
gateway_urls,
max_grant_lifetime_seconds,
max_installation_lifetime_seconds,
endpoint_quota_window_seconds,
@@ -339,10 +286,6 @@ mod tests {
STANDARD.encode([4; 32])
),
),
(
"BUZZ_PUSH_GATEWAY_ORIGIN".into(),
"https://push.example".into(),
),
(
"BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS".into(),
"2592000".into(),
@@ -399,31 +342,21 @@ mod tests {
}
#[test]
fn gateway_transport_uses_configured_origin_and_transcript_audiences_stay_registered() {
let config = Config::from_map(&base()).unwrap();
assert_eq!(config.gateway_urls.origin.as_str(), "https://push.example/");
fn startup_needs_no_origin_and_transcript_audiences_stay_registered() {
assert!(!base().contains_key("BUZZ_PUSH_GATEWAY_ORIGIN"));
Config::from_map(&base()).unwrap();
assert_eq!(ENROLL_AUDIENCE, "https://push.buzz.xyz/v1/installations");
assert_eq!(DELEGATE_AUDIENCE, "https://push.buzz.xyz/v1/delegations");
assert_eq!(
config.gateway_urls.delivery.as_str(),
"https://push.example/v1/deliveries/apns"
);
assert_eq!(
config.gateway_urls.enroll_audience,
"https://push.buzz.xyz/v1/installations"
);
assert_eq!(
config.gateway_urls.delegate_audience,
"https://push.buzz.xyz/v1/delegations"
);
assert_eq!(
config.gateway_urls.rotate_endpoint_audience,
ROTATE_ENDPOINT_AUDIENCE,
"https://push.buzz.xyz/v1/installations/endpoint"
);
assert_eq!(
config.gateway_urls.revoke_delegation_audience,
REVOKE_DELEGATION_AUDIENCE,
"https://push.buzz.xyz/v1/delegations/revoke"
);
assert_eq!(
config.gateway_urls.revoke_installation_audience,
REVOKE_INSTALLATION_AUDIENCE,
"https://push.buzz.xyz/v1/installations/revoke"
);
}
@@ -441,10 +374,6 @@ mod tests {
#[test]
fn malformed_security_configuration_fails_startup() {
for (key, value) in [
("BUZZ_PUSH_GATEWAY_ORIGIN", "http://push.example"),
("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example/path"),
("BUZZ_PUSH_GATEWAY_ORIGIN", "https://push.example?token=x"),
("BUZZ_PUSH_GATEWAY_ORIGIN", "https://user@push.example"),
("BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", ""),
("BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", "staging"),
("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"),

View File

@@ -5,14 +5,17 @@ use crate::{
authority::{
AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation,
},
config::GatewayUrls,
config::{
DELEGATE_AUDIENCE, ENROLL_AUDIENCE, REVOKE_DELEGATION_AUDIENCE,
REVOKE_INSTALLATION_AUDIENCE, ROTATE_ENDPOINT_AUDIENCE,
},
grant::GrantKeyring,
model::*,
token::TokenKeyring,
};
use axum::{
body::Bytes,
extract::State,
extract::{OriginalUri, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
@@ -21,7 +24,7 @@ use axum::{
use base64::{engine::general_purpose::STANDARD, Engine as _};
use nostr::{
nips::nip98::{verify_auth_header, HttpMethod},
Event, JsonUtil, Timestamp,
Event, JsonUtil, TagKind, TagStandard, Timestamp,
};
use std::{
sync::{
@@ -47,8 +50,6 @@ pub struct AppState {
/// Server-owned dogfood application identity and APNs transport. The wire
/// profile selector is fixed and App Attest verifies the configured app ID.
pub profile: Arc<ProfileRuntime>,
/// Security-sensitive endpoints and audiences derived from one gateway origin.
pub gateway_urls: Arc<GatewayUrls>,
pub max_grant_lifetime_seconds: i64,
pub max_installation_lifetime_seconds: i64,
pub endpoint_quota_window_seconds: i64,
@@ -71,14 +72,46 @@ fn valid_relay_pubkey(v: &str) -> bool {
&& v.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn auth_event_id(header: &str) -> Option<String> {
pub(crate) const DELIVERY_PATH: &str = "/v1/deliveries/apns";
// Keep preliminary decoding bounded to the pinned nostr verifier's limits.
fn delivery_auth(header: &str, body: &[u8]) -> Option<(String, String)> {
let (prefix, encoded) = header.split_once(' ')?;
if prefix != "Nostr" {
const MAX_EVENT_BYTES: usize = 64 * 1024;
const MAX_ENCODED_BYTES: usize = MAX_EVENT_BYTES.div_ceil(3) * 4;
if prefix != "Nostr" || encoded.len() > MAX_ENCODED_BYTES {
return None;
}
Event::from_json(STANDARD.decode(encoded).ok()?)
.ok()
.map(|e| e.id.to_hex())
let decoded = STANDARD.decode(encoded).ok()?;
if decoded.len() > MAX_EVENT_BYTES {
return None;
}
let event = Event::from_json(decoded).ok()?;
let signed_url = match event.tags.find_standardized(TagKind::u())? {
TagStandard::AbsoluteURL(url) => url,
_ => return None,
};
if !matches!(signed_url.scheme(), "http" | "https")
|| signed_url.path() != DELIVERY_PATH
|| signed_url.query().is_some()
|| signed_url.fragment().is_some()
|| !signed_url.username().is_empty()
|| signed_url.password().is_some()
{
return None;
}
// This delivery profile binds method and path, not request origin. Passing
// the validated signed URL retains the library's kind, method, timestamp,
// body hash, event ID and signature checks without reconstructing a URL.
let relay = verify_auth_header(
header,
signed_url,
HttpMethod::POST,
Timestamp::now(),
Some(body),
)
.ok()?;
Some((event.id.to_hex(), relay.to_hex()))
}
fn decode_challenge(value: &str) -> Option<[u8; 32]> {
@@ -192,7 +225,7 @@ async fn enroll(State(s): State<AppState>, body: Bytes) -> Response {
};
let t = EnrollTranscript {
v: r.v,
audience: &s.gateway_urls.enroll_audience,
audience: ENROLL_AUDIENCE,
challenge_id: r.challenge_id,
challenge: &r.challenge,
key_id: &r.key_id,
@@ -366,7 +399,7 @@ async fn delegate(State(s): State<AppState>, body: Bytes) -> Response {
}
let t = DelegateTranscript {
v: r.v,
audience: &s.gateway_urls.delegate_audience,
audience: DELEGATE_AUDIENCE,
challenge_id: r.challenge_id,
challenge: &r.challenge,
installation_handle: r.installation_handle,
@@ -463,7 +496,7 @@ async fn rotate_endpoint(State(s): State<AppState>, body: Bytes) -> Response {
};
let t = RotateTranscript {
v: r.v,
audience: &s.gateway_urls.rotate_endpoint_audience,
audience: ROTATE_ENDPOINT_AUDIENCE,
challenge_id: r.challenge_id,
challenge: &r.challenge,
installation_handle: r.installation_handle,
@@ -526,7 +559,7 @@ async fn revoke_delegation(State(s): State<AppState>, body: Bytes) -> Response {
}
let t = RevokeDelegationTranscript {
v: r.v,
audience: &s.gateway_urls.revoke_delegation_audience,
audience: REVOKE_DELEGATION_AUDIENCE,
challenge_id: r.challenge_id,
challenge: &r.challenge,
installation_handle: r.installation_handle,
@@ -581,7 +614,7 @@ async fn revoke_installation(State(s): State<AppState>, body: Bytes) -> Response
}
let t = RevokeInstallationTranscript {
v: r.v,
audience: &s.gateway_urls.revoke_installation_audience,
audience: REVOKE_INSTALLATION_AUDIENCE,
challenge_id: r.challenge_id,
challenge: &r.challenge,
installation_handle: r.installation_handle,
@@ -618,7 +651,12 @@ async fn revoke_installation(State(s): State<AppState>, body: Bytes) -> Response
}
}
async fn deliver(State(s): State<AppState>, headers: HeaderMap, body: Bytes) -> Response {
async fn deliver(
State(s): State<AppState>,
OriginalUri(uri): OriginalUri,
headers: HeaderMap,
body: Bytes,
) -> Response {
let r: DeliveryRequest = match crate::strict_json::from_slice(&body) {
Ok(x) => x,
Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"),
@@ -633,20 +671,13 @@ async fn deliver(State(s): State<AppState>, headers: HeaderMap, body: Bytes) ->
Some(x) => x,
None => return error(StatusCode::UNAUTHORIZED, "invalid_auth"),
};
let event_id = match auth_event_id(auth) {
Some(x) => x,
if uri.query().is_some() {
return error(StatusCode::UNAUTHORIZED, "invalid_auth");
}
let (event_id, relay) = match delivery_auth(auth, &body) {
Some(auth) => auth,
None => return error(StatusCode::UNAUTHORIZED, "invalid_auth"),
};
let relay = match verify_auth_header(
auth,
&s.gateway_urls.delivery,
HttpMethod::POST,
Timestamp::now(),
Some(&body),
) {
Ok(x) => x.to_hex(),
Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_auth"),
};
let grant = match s.grant_keyring.open(&r.endpoint_grant) {
Ok(x) => x,
Err(_) => return error(StatusCode::NOT_FOUND, "invalid_grant"),
@@ -828,7 +859,7 @@ pub fn router_with_metrics(
.route("/v1/delegations/revoke", post(revoke_delegation))
.route("/v1/installations/endpoint", post(rotate_endpoint))
.route("/v1/installations/revoke", post(revoke_installation))
.route("/v1/deliveries/apns", post(deliver))
.route(DELIVERY_PATH, post(deliver))
.layer(RequestBodyLimitLayer::new(MAX_REQUEST_BYTES));
let public = Router::new()
.merge(enrollment)
@@ -906,9 +937,6 @@ mod request_limit_tests {
app_attest: Arc::new(app_attest),
transport: Arc::new(NeverTransport),
}),
gateway_urls: Arc::new(
GatewayUrls::from_origin("https://push.example".parse().unwrap()).unwrap(),
),
max_grant_lifetime_seconds: 86_400,
max_installation_lifetime_seconds: 86_400,
endpoint_quota_window_seconds: 60,

View File

@@ -0,0 +1,370 @@
//! Delivery authentication through the public router, with an in-memory authority
//! store and APNs transport. No external service or credentials are used.
use std::sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc,
};
use crate::{
apns::{DeliveryAttempt, DeliveryOutcome, PushTransport},
app_attest::AppAttestVerifier,
authority::{AuthorityStore, Delegation, MemoryAuthorityStore, NewInstallation},
grant::{GrantKey, GrantKeyring},
http::ProfileRuntime,
model::{AppProfile, EndpointGrant},
router,
token::{TokenKey, TokenKeyring},
AppState,
};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use sha2::{Digest, Sha256};
use tower::ServiceExt;
use uuid::Uuid;
const DIRECT_URL: &str = "http://push.example:8080/v1/deliveries/apns";
const FORWARDED_URL: &str = "https://push.example/v1/deliveries/apns";
struct TestTransport(Arc<AtomicUsize>);
#[async_trait::async_trait]
impl PushTransport for TestTransport {
async fn send(&self, _: DeliveryAttempt, endpoint: &str) -> DeliveryOutcome {
assert_eq!(endpoint, "01020304");
self.0.fetch_add(1, Ordering::SeqCst);
DeliveryOutcome::Accepted
}
}
fn now() -> i64 {
1_750_000_000
}
async fn fixture() -> (axum::Router, Keys, Vec<u8>, Arc<AtomicUsize>) {
let keys = Keys::generate();
let authority = Arc::new(MemoryAuthorityStore::default());
let token_keyring =
Arc::new(TokenKeyring::new(vec![TokenKey::new("test", &[2; 32]).unwrap()]).unwrap());
let grant_keyring =
Arc::new(GrantKeyring::new(vec![GrantKey::new("test", &[1; 32]).unwrap()]).unwrap());
let installation_id = Uuid::new_v4();
authority
.create_installation(
NewInstallation {
id: installation_id,
app_attest_key_id: vec![3; 32],
app_attest_public_key: vec![4; 65],
assertion_counter: 0,
profile: AppProfile::BuzzIosDogfood,
token_ciphertext: token_keyring.seal(&[1, 2, 3, 4]).unwrap(),
token_fingerprint: [5; 32],
endpoint_epoch: 1,
expires_at: now() + 600,
},
now(),
)
.await
.unwrap();
let delegation_id = Uuid::new_v4();
authority
.upsert_delegation(Delegation {
id: delegation_id,
installation_id,
relay_pubkey: keys.public_key().to_hex(),
endpoint_epoch: 1,
generation: 1,
not_before: now(),
expires_at: now() + 600,
revoked: false,
})
.await
.unwrap();
let grant = grant_keyring
.issue(&EndpointGrant {
v: 1,
delegation_id,
relay_pubkey: keys.public_key().to_hex(),
app_profile: AppProfile::BuzzIosDogfood,
endpoint_epoch: 1,
generation: 1,
expires_at: now() + 600,
})
.unwrap();
let sends = Arc::new(AtomicUsize::new(0));
let (app, _) = router(AppState {
grant_keyring,
authority,
token_keyring,
profile: Arc::new(ProfileRuntime {
app_attest: Arc::new(
AppAttestVerifier::new(
"TEAMID.xyz.block.buzz.dogfood.mobile".into(),
include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(),
)
.unwrap(),
),
transport: Arc::new(TestTransport(sends.clone())),
}),
max_grant_lifetime_seconds: 600,
max_installation_lifetime_seconds: 600,
endpoint_quota_window_seconds: 60,
endpoint_quota_max_deliveries: 10,
now,
accepting: Arc::new(AtomicBool::new(true)),
});
let body = serde_json::to_vec(&serde_json::json!({
"v": 1, "endpoint_grant": grant, "request_id": Uuid::new_v4(), "expires_at": now() + 60,
}))
.unwrap();
(app, keys, body, sends)
}
fn signed_header(keys: &Keys, url: &str, method: &str, body: &[u8]) -> String {
let hash = hex::encode(Sha256::digest(body));
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags([
Tag::parse(["u", url]).unwrap(),
Tag::parse(["method", method]).unwrap(),
Tag::parse(["payload", &hash]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
format!(
"Nostr {}",
STANDARD.encode(serde_json::to_vec(&event).unwrap())
)
}
fn request(url: &str, proto: Option<&str>, auth: String, body: Vec<u8>) -> Request<Body> {
let url = url::Url::parse(url).unwrap();
let authority = &url[url::Position::BeforeHost..url::Position::AfterPort];
let path = &url[url::Position::BeforePath..url::Position::AfterQuery];
let mut request = Request::post(path)
.header("host", authority)
.header("authorization", auth);
if let Some(proto) = proto {
request = request.header("x-forwarded-proto", proto);
}
request.body(Body::from(body)).unwrap()
}
#[tokio::test]
async fn delivery_binds_path_not_origin_or_forwarding_headers() {
for signed_url in [
DIRECT_URL,
FORWARDED_URL,
"https://other.example:8443/v1/deliveries/apns",
] {
let (app, keys, body, sends) = fixture().await;
let auth = signed_header(&keys, signed_url, "POST", &body);
let mut request = request(DIRECT_URL, Some("invalid,https"), auth, body);
request.headers_mut().remove("host");
request
.headers_mut()
.append("x-forwarded-proto", "http".parse().unwrap());
request
.headers_mut()
.insert("x-forwarded-host", "unrelated.example".parse().unwrap());
request.headers_mut().insert(
"forwarded",
"proto=ftp;host=unrelated.example".parse().unwrap(),
);
assert_eq!(app.oneshot(request).await.unwrap().status(), StatusCode::OK);
assert_eq!(sends.load(Ordering::SeqCst), 1);
}
}
#[tokio::test]
async fn signature_is_bound_to_delivery_path_method_and_body() {
for (url, method, change_body) in [
("https://push.example/v1/other", "POST", false),
("https://push.example/v1/deliveries/apns/", "POST", false),
("https://push.example/v1/deliveries/%61pns", "POST", false),
(DIRECT_URL, "GET", false),
(DIRECT_URL, "POST", true),
] {
let (app, keys, mut body, sends) = fixture().await;
let auth = signed_header(&keys, url, method, &body);
if change_body {
body.push(b' ');
}
assert_eq!(
app.oneshot(request(DIRECT_URL, None, auth, body))
.await
.unwrap()
.status(),
StatusCode::UNAUTHORIZED,
"{url} {method}"
);
assert_eq!(sends.load(Ordering::SeqCst), 0);
}
}
#[tokio::test]
async fn signed_url_modifiers_and_non_http_schemes_are_rejected() {
for url in [
"https://push.example/v1/deliveries/apns?mode=one",
"https://push.example/v1/deliveries/apns?",
"https://push.example/v1/deliveries/apns#fragment",
"https://push.example/v1/deliveries/apns#",
"https://user@push.example/v1/deliveries/apns",
"ftp://push.example/v1/deliveries/apns",
] {
let (app, keys, body, sends) = fixture().await;
let auth = signed_header(&keys, url, "POST", &body);
assert_eq!(
app.oneshot(request(DIRECT_URL, None, auth, body))
.await
.unwrap()
.status(),
StatusCode::UNAUTHORIZED,
"{url}"
);
assert_eq!(sends.load(Ordering::SeqCst), 0);
}
}
#[tokio::test]
async fn request_queries_are_rejected() {
let (app, keys, body, sends) = fixture().await;
let auth = signed_header(&keys, DIRECT_URL, "POST", &body);
assert_eq!(
app.oneshot(request(&format!("{DIRECT_URL}?mode=one"), None, auth, body))
.await
.unwrap()
.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(sends.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn router_rejects_other_methods_and_paths() {
for (method, path, expected) in [
(
"GET",
crate::http::DELIVERY_PATH,
StatusCode::METHOD_NOT_ALLOWED,
),
("POST", "/v1/other", StatusCode::NOT_FOUND),
] {
let (app, keys, body, sends) = fixture().await;
let auth = signed_header(&keys, DIRECT_URL, "POST", &body);
let request = Request::builder()
.method(method)
.uri(path)
.header("authorization", auth)
.body(Body::from(body))
.unwrap();
assert_eq!(app.oneshot(request).await.unwrap().status(), expected);
assert_eq!(sends.load(Ordering::SeqCst), 0);
}
}
#[tokio::test]
async fn verifier_retains_kind_timestamp_and_payload_requirements() {
for (kind, timestamp, payload) in [
(Kind::TextNote, Timestamp::now(), true),
(
Kind::HttpAuth,
Timestamp::from(Timestamp::now().as_secs() - 120),
true,
),
(
Kind::HttpAuth,
Timestamp::from(Timestamp::now().as_secs() + 120),
true,
),
(Kind::HttpAuth, Timestamp::now(), false),
] {
let (app, keys, body, sends) = fixture().await;
let hash = hex::encode(Sha256::digest(&body));
let mut tags = vec![
Tag::parse(["u", DIRECT_URL]).unwrap(),
Tag::parse(["method", "POST"]).unwrap(),
];
if payload {
tags.push(Tag::parse(["payload", &hash]).unwrap());
}
let event = EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(timestamp)
.sign_with_keys(&keys)
.unwrap();
let auth = format!(
"Nostr {}",
STANDARD.encode(serde_json::to_vec(&event).unwrap())
);
assert_eq!(
app.oneshot(request(DIRECT_URL, None, auth, body))
.await
.unwrap()
.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(sends.load(Ordering::SeqCst), 0);
}
}
#[tokio::test]
async fn invalid_event_id_and_signature_are_rejected() {
for field in ["id", "sig"] {
let (app, keys, body, sends) = fixture().await;
let auth = signed_header(&keys, DIRECT_URL, "POST", &body);
let mut event: serde_json::Value = serde_json::from_slice(
&STANDARD
.decode(auth.strip_prefix("Nostr ").unwrap())
.unwrap(),
)
.unwrap();
let size = event[field].as_str().unwrap().len();
event[field] = "0".repeat(size).into();
let auth = format!(
"Nostr {}",
STANDARD.encode(serde_json::to_vec(&event).unwrap())
);
assert_eq!(
app.oneshot(request(DIRECT_URL, None, auth, body))
.await
.unwrap()
.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(sends.load(Ordering::SeqCst), 0);
}
}
#[tokio::test]
async fn grant_signer_and_replay_checks_remain_enforced() {
let (app, keys, body, sends) = fixture().await;
let wrong_signer = signed_header(&Keys::generate(), DIRECT_URL, "POST", &body);
assert_eq!(
app.clone()
.oneshot(request(DIRECT_URL, None, wrong_signer, body.clone()))
.await
.unwrap()
.status(),
StatusCode::NOT_FOUND
);
assert_eq!(sends.load(Ordering::SeqCst), 0);
let auth = signed_header(&keys, DIRECT_URL, "POST", &body);
assert_eq!(
app.clone()
.oneshot(request(DIRECT_URL, None, auth.clone(), body.clone()))
.await
.unwrap()
.status(),
StatusCode::OK
);
assert_eq!(
app.oneshot(request(DIRECT_URL, None, auth, body))
.await
.unwrap()
.status(),
StatusCode::NOT_FOUND
);
assert_eq!(sends.load(Ordering::SeqCst), 1);
}

View File

@@ -11,3 +11,6 @@ pub mod postgres;
pub(crate) mod strict_json;
pub mod token;
pub use http::{router, router_with_metrics, AppState};
#[cfg(test)]
mod http_delivery_tests;

View File

@@ -97,7 +97,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
authority,
token_keyring: Arc::new(token_keyring),
profile: Arc::new(profile),
gateway_urls: Arc::new(c.gateway_urls),
max_grant_lifetime_seconds: c.max_grant_lifetime_seconds,
max_installation_lifetime_seconds: c.max_installation_lifetime_seconds,
endpoint_quota_window_seconds: c.endpoint_quota_window_seconds,

View File

@@ -18,7 +18,6 @@ fn parsed_configuration_reaches_executable_verifier() {
"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()),
(

View File

@@ -346,7 +346,7 @@ pub struct Config {
pub push_enabled: bool,
/// Descriptor key identifier accepted in kind:30350 `exec` tags.
pub push_executor_key_id: String,
/// Exact HTTPS gateway endpoint used to submit client-authorized APNs delivery capabilities.
/// Exact HTTP(S) gateway endpoint used to sign and submit APNs delivery capabilities.
/// Required while push is enabled. An explicitly empty setting is allowed
/// only while push is disabled.
pub push_gateway_delivery_url: Option<url::Url>,
@@ -461,9 +461,9 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
"BUZZ_PUSH_GATEWAY_DELIVERY_URL is not a valid URL: {e}"
))
})?;
if url.scheme() != "https"
if !matches!(url.scheme(), "http" | "https")
|| url.host().is_none()
|| url.port().is_some()
|| (url.scheme() == "https" && url.port().is_some())
|| !url.username().is_empty()
|| url.password().is_some()
|| url.path() != "/v1/deliveries/apns"
@@ -471,7 +471,7 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
|| url.fragment().is_some()
{
return Err(ConfigError::InvalidValue(
"BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTPS /v1/deliveries/apns URL without an explicit port, credentials, query, or fragment"
"BUZZ_PUSH_GATEWAY_DELIVERY_URL must be an exact HTTP(S) /v1/deliveries/apns URL without credentials, query, or fragment; explicit ports are supported only for HTTP"
.to_string(),
));
}
@@ -2268,8 +2268,19 @@ mod tests {
#[test]
fn push_gateway_url_is_exact_and_fail_closed() {
assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok());
let delivery_url = "http://push.example:8080/v1/deliveries/apns";
assert_eq!(
parse_push_gateway_delivery_url(delivery_url)
.unwrap()
.as_str(),
delivery_url
);
assert!(parse_push_gateway_delivery_url("http://push-gateway/v1/deliveries/apns").is_ok());
for invalid in [
"http://push.example/v1/deliveries/apns",
"ftp://push.example/v1/deliveries/apns",
"http://push-gateway:8080/v1/deliveries/apns?token=x",
"http://user@push-gateway:8080/v1/deliveries/apns",
"http://push-gateway:8080/v1/deliveries/apns#fragment",
"https://push.example:8443/v1/deliveries/apns",
"https://push.example/v1/deliveries/apns/",
"https://push.example/v1/deliveries/apns?token=x",

View File

@@ -689,7 +689,13 @@ fn class_rank(_: &str) -> u8 {
#[cfg(test)]
mod tests {
use super::*;
use axum::{extract::State, routing::post, Json, Router};
use axum::{
body::Bytes,
extract::{OriginalUri, State},
http::HeaderMap,
routing::post,
Json, Router,
};
use serde_json::Value;
use std::{future::IntoFuture, sync::Arc};
use tokio::sync::Mutex;
@@ -729,9 +735,25 @@ mod tests {
async fn capture(
State(seen): State<Arc<Mutex<Vec<Value>>>>,
Json(body): Json<Value>,
OriginalUri(uri): OriginalUri,
headers: HeaderMap,
body: Bytes,
) -> Json<Value> {
seen.lock().await.push(body);
let url: url::Url = format!("http://{}{}", headers["host"].to_str().unwrap(), uri)
.parse()
.unwrap();
assert!(!headers.contains_key("x-forwarded-proto"));
nostr::nips::nip98::verify_auth_header(
headers["authorization"].to_str().unwrap(),
&url,
nostr::nips::nip98::HttpMethod::POST,
nostr::Timestamp::now(),
Some(&body),
)
.unwrap();
seen.lock()
.await
.push(serde_json::from_slice(&body).unwrap());
Json(serde_json::json!({"status":"accepted"}))
}
@@ -744,12 +766,14 @@ mod tests {
axum::serve(
listener,
Router::new()
.route("/deliver", post(capture))
.route("/v1/deliveries/apns", post(capture))
.with_state(seen.clone()),
)
.into_future(),
);
let url: url::Url = format!("http://{address}/deliver").parse().unwrap();
let url: url::Url = format!("http://{address}/v1/deliveries/apns")
.parse()
.unwrap();
let http = reqwest::Client::new();
let keys = nostr::Keys::generate();
let request_id = uuid::Uuid::new_v4();

View File

@@ -32,7 +32,6 @@ spec:
env:
- { name: BUZZ_PUSH_BIND_ADDR, value: "0.0.0.0:8080" }
- { name: BUZZ_PUSH_HEALTH_ADDR, value: "0.0.0.0:8081" }
- { name: BUZZ_PUSH_GATEWAY_ORIGIN, value: {{ required "gatewayOrigin is required" .Values.gatewayOrigin | quote }} }
{{/* Schema bounds this integer; normalize YAML numbers before quote can emit scientific notation. */}}
- { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | int64 | quote }} }
- { name: BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH, value: /run/buzz/app-attest/root.pem }

View File

@@ -8,7 +8,7 @@ trap 'rm -f "$fixture"' EXIT
env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -rjson -ryaml -ropen3 \
- "$fixture" <<'RUBY'
chart = 'deploy/charts/buzz-push-gateway'
command = ['helm', 'template', 'push', chart, '--set', 'gatewayOrigin=https://push.example']
command = ['helm', 'template', 'push', chart]
cases = []
render = lambda do |label, expected, args, values|
output, error, status = Open3.capture3(*command, *args, stdin_data: values)

View File

@@ -5,15 +5,14 @@ out=$(mktemp); production_out=$(mktemp); route_out=$(mktemp); datadog_out=$(mkte
trap 'rm -f "$out" "$production_out" "$route_out" "$datadog_out" "${monitoring_out:-}"' EXIT
gateway_origin_arg=(--set 'gatewayOrigin=https://push.example')
# Generic values require the deployment-owned gateway origin.
helm lint deploy/charts/buzz-push-gateway "${gateway_origin_arg[@]}" >/dev/null
helm template push deploy/charts/buzz-push-gateway "${gateway_origin_arg[@]}" >"$out"
# Generic values work without an application origin.
helm lint deploy/charts/buzz-push-gateway >/dev/null
helm template push deploy/charts/buzz-push-gateway >"$out"
# Production values support a platform-owned ingress without rendering an
# HTTPRoute. The environment-owned inputs remain mandatory.
production_args=(
-f deploy/charts/buzz-push-gateway/values-production.yaml
--set 'image.digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
--set 'gatewayOrigin=https://push.example'
--set 'profiles.dogfood.appAttestAppId=REALTEAM.xyz.block.buzz.dogfood.mobile'
--set 'networkPolicy.postgresEgressCidrs[0]=10.42.0.0/16'
)
@@ -79,12 +78,9 @@ required = Set.new(%w[
DATABASE_URL BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH
BUZZ_PUSH_DOGFOOD_APNS_TOPIC BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID
BUZZ_PUSH_GRANT_KEYS BUZZ_PUSH_TOKEN_KEYS BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS
BUZZ_PUSH_GATEWAY_ORIGIN
])
assert!(required.subset?(env_names))
gateway_origin = d.dig("spec", "template", "spec", "containers", 0, "env")
.find { |entry| entry["name"] == "BUZZ_PUSH_GATEWAY_ORIGIN" }
assert!(gateway_origin["value"] == "https://push.example")
assert!(!env_names.include?("BUZZ_PUSH_GATEWAY_ORIGIN"))
assert!(!env_names.any? { |name| name.include?("APP_STORE") })
apns_volume = d.dig("spec", "template", "spec", "volumes").find { |volume| volume["name"] == "apns-dogfood" }
assert!(apns_volume.dig("secret", "defaultMode") == 0o400, apns_volume.inspect)
@@ -123,9 +119,10 @@ assert!(!route.dig("spec", "parentRefs").empty?)
assert!(route.dig("spec", "hostnames") == ["push.example"])
RUBY
# A gateway origin is a required deployment input, even when HTTPRoute is off.
if helm template push deploy/charts/buzz-push-gateway >/dev/null 2>&1; then
echo 'expected missing gatewayOrigin to fail' >&2
# An origin is needed only to produce the optional routing hostname.
if helm template push deploy/charts/buzz-push-gateway \
--set httpRoute.enabled=true --set 'httpRoute.parentRefs[0].name=gateway' >/dev/null 2>&1; then
echo 'expected enabled HTTPRoute without gatewayOrigin to fail' >&2
exit 1
fi
for invalid_origin in 'http://push.example' 'https://push.example:8443' 'https://push.example/base'; do

View File

@@ -3,6 +3,7 @@
image:
tag: ""
digest: ""
# Optional routing input, required only when httpRoute.enabled=true.
gatewayOrigin: ""
profiles:
dogfood:

View File

@@ -13,8 +13,8 @@
},
"gatewayOrigin": {
"type": "string",
"format": "uri",
"pattern": "^https://[^/?#@:]+/?$"
"description": "Routing origin for the optional HTTPRoute; not passed to the gateway binary.",
"pattern": "^(https://[^/?#@:]+/?)?$"
},
"maxGrantLifetimeSeconds": {
"type": "integer",
@@ -306,7 +306,6 @@
"required": [
"replicaCount",
"existingSecret",
"gatewayOrigin",
"maxGrantLifetimeSeconds",
"profiles",
"httpRoute",

View File

@@ -18,8 +18,7 @@ migration:
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 250m, memory: 128Mi}
# Exact externally reachable HTTPS origin. The gateway derives transport routes
# from this value; NIP-PL v1 App Attest audiences remain registered constants.
# Routing origin used only when httpRoute.enabled=true. Not passed to the binary.
gatewayOrigin: ""
maxGrantLifetimeSeconds: 2592000
profiles:

View File

@@ -395,13 +395,13 @@ Success is `200 {"status":"revoked"}`. The revocation atomically invalidates the
### Relay delivery
`POST /v1/deliveries/apns` has the exact externally configured URL `https://push.buzz.xyz/v1/deliveries/apns`. Request:
The relay sends requests to the configured HTTP(S) delivery URL ending in `/v1/deliveries/apns`. Delivery authentication uses the NIP-98 event format with method/path binding rather than full request-URL matching. The signed method MUST be `POST` and the signed HTTP(S) URL path MUST be exactly `/v1/deliveries/apns`, without query, fragment or credentials. The gateway does not compare scheme, host or port to the incoming request and rejects incoming request queries. `https://push.buzz.xyz/v1/deliveries/apns` is an example. Request:
```json
{"v":1,"endpoint_grant":"<opaque-capability>","request_id":"<uuid>","expires_at":<unix-seconds>}
```
The relay supplies a NIP-98 `Authorization: Nostr <standard-base64-event-json>` header for method `POST`, the exact URL above, and the SHA-256 payload hash of the **received request body bytes**. The gateway verifies the NIP-98 event signature, timestamp under NIP-98 rules, method, URL, and payload; the event pubkey is the relay identity. It decrypts `endpoint_grant`, requires that signer, current installation/delegation, endpoint epoch and generation, and both `now <= request.expires_at <= grant.expires_at`. Every NIP-98 event id is burned at admission.
The relay supplies a NIP-98 `Authorization: Nostr <standard-base64-event-json>` header for method `POST`, the configured delivery URL, and the SHA-256 payload hash of the **received request body bytes**. The gateway verifies the event ID and signature, event kind, timestamp under NIP-98 rules, method, signed URL path, and payload; the event pubkey is the relay identity. It decrypts `endpoint_grant`, requires that signer, current installation/delegation, endpoint epoch and generation, and both `now <= request.expires_at <= grant.expires_at`. Every NIP-98 event id is burned at admission.
The relay's durable job UUID is `request_id` and becomes the stable APNs `apns-id`. Delivery replay/quota reservation is one transaction. The commit of that transaction is send-begin: a revocation or rotation commit that completes first prevents the old-capability send; a send admitted first may finish. Terminal outcomes retain the `(relay_pubkey, request_id)` reservation; transient/configuration outcomes release it only after provider processing so a fresh NIP-98 event may retry the same job. Endpoint quota is charged once per admitted attempt and never refunded. A crash before transient cleanup can reject that id until its bounded request expiry; exactly-once provider delivery is not guaranteed.

View File

@@ -4,7 +4,7 @@
## Network and health
- Public listener: `BUZZ_PUSH_BIND_ADDR` (default `0.0.0.0:8080`). Route the configured `BUZZ_PUSH_GATEWAY_ORIGIN` to this port.
- Public listener: `BUZZ_PUSH_BIND_ADDR` (default `0.0.0.0:8080`). Route the deployment hostname to this port; the binary needs no public-origin setting.
- Private health listener: `BUZZ_PUSH_HEALTH_ADDR` (default `0.0.0.0:8081`). Probe `/_liveness` and `/_readiness`; do not expose this port publicly. The chart has no pod-ingress allowance for 8081; Kubernetes node/kubelet-origin probe traffic is exempt from NetworkPolicy. Add a narrowly selected monitoring source only if the target CNI requires pod-origin health scraping.
- Readiness fails when PostgreSQL authority is unavailable. Graceful shutdown stops accepting new requests before draining in-flight APNs calls.
@@ -13,7 +13,6 @@
| Variable | Purpose |
|---|---|
| `DATABASE_URL` | PostgreSQL authority/admission store. Runtime credentials need DML on the six gateway tables, not DDL. |
| `BUZZ_PUSH_GATEWAY_ORIGIN` | Exact externally reachable HTTPS origin. No credentials, port, path, query, or fragment. The gateway derives its transport routes from it; NIP-PL v1 App Attest audiences remain the registered `https://push.buzz.xyz/v1/...` constants. |
| `BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS` | Maximum delegation capability lifetime (`1..=31536000`). |
| `BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS` | Maximum encrypted-token installation lifetime (default 90 days, max one year). Clients must renew before expiry. |
| `BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. |
@@ -168,7 +167,8 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe
Relay push is an explicit deployment opt-in through `BUZZ_PUSH_ENABLED=true`;
the established strict boolean parser rejects unknown values and the default is
false. When enabled, `BUZZ_PUSH_GATEWAY_DELIVERY_URL` is required and must be an
exact HTTPS `/v1/deliveries/apns` URL. An absent or explicitly empty URL while
exact HTTP(S) `/v1/deliveries/apns` URL. Credentials, query and fragment are
not accepted. The relay signs and sends the same configured URL. An absent or explicitly empty URL while
enabled is a startup error. Only an enabled relay
advertises its host-scoped NIP-PL descriptor, accepts leases, and starts the
matcher and delivery worker. Relays retain lease matching, authorization, durable
@@ -273,7 +273,9 @@ the environment's GitOps values; the chart then renders
`ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag.
`values-production.yaml` remains an intentionally invalid production-input
contract: deployment CI must inject the verified image digest, the provisioned
dogfood Apple application identifier, `gatewayOrigin`, and the actual PostgreSQL network. In an
dogfood Apple application identifier and the actual PostgreSQL network. Set
`gatewayOrigin` only when enabling the chart’s optional HTTPRoute; it supplies
the routing hostname and is not passed to the gateway binary. In an
environment with an existing ingress or service mesh route, keep
`httpRoute.enabled=false`. If this chart owns a Gateway API route, enable it and
inject an environment-owned `parentRef`; schema validation rejects an enabled
@@ -320,3 +322,14 @@ before use:
helm show chart oci://ghcr.io/block/buzz/charts/buzz-push-gateway --version X.Y.Z
helm pull oci://ghcr.io/block/buzz/charts/buzz-push-gateway --version X.Y.Z
```
### Delivery URL at the gateway
The gateway verifies the signed method and delivery path using the NIP-98 event
format, rather than matching the full incoming HTTP(S) request URL. The signed
method must be `POST`; the signed URL path must be `/v1/deliveries/apns`, without
query, fragment or credentials. Incoming request queries are rejected.
Host and forwarding headers do not participate in delivery authentication.
Event signatures, body hashes, timestamps, grant checks and replay protection
remain enforced. No mobile App Attest audiences or relay activation settings change.