feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+50
View File
@@ -0,0 +1,50 @@
[package]
name = "buzz-push-gateway"
description = "Blind, capability-gated NIP-PL gateway for the Buzz mobile app"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[lib]
name = "buzz_push_gateway"
path = "src/lib.rs"
[[bin]]
name = "buzz-push-gateway"
path = "src/main.rs"
[dependencies]
aes-gcm = "0.10"
appattest = { version = "0.1.1", default-features = false }
axum = { workspace = true }
async-trait = "0.1"
base64 = "0.22"
byteorder = "1.5"
minicbor = "0.25"
chrono = { workspace = true }
hex = { workspace = true }
getrandom = "0.4"
metrics = { workspace = true }
metrics-exporter-prometheus = { workspace = true }
nostr = { workspace = true }
p256 = { version = "0.14", features = ["ecdsa", "pem", "pkcs8"] }
rand = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
reqwest = { workspace = true }
@@ -0,0 +1,66 @@
-- Durable, deployment-global authority for the public NIP-PL push gateway.
-- This state is intentionally outside relay community tenancy: installations
-- delegate to relay signing keys and may authorize multiple relay deployments.
CREATE TABLE push_gateway_challenges (
id UUID PRIMARY KEY,
challenge_hash BYTEA NOT NULL CHECK (length(challenge_hash) = 32),
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX push_gateway_challenges_expiry ON push_gateway_challenges (expires_at);
CREATE TABLE push_gateway_installations (
id UUID PRIMARY KEY,
app_attest_key_id BYTEA NOT NULL UNIQUE CHECK (octet_length(app_attest_key_id) BETWEEN 1 AND 128),
app_attest_public_key BYTEA NOT NULL CHECK (octet_length(app_attest_public_key) BETWEEN 33 AND 256),
assertion_counter BIGINT NOT NULL CHECK (assertion_counter BETWEEN 0 AND 4294967295),
app_profile TEXT NOT NULL CHECK (app_profile IN ('buzz-ios-production','buzz-ios-sandbox')),
token_ciphertext BYTEA NOT NULL CHECK (octet_length(token_ciphertext) BETWEEN 1 AND 2048),
token_fingerprint BYTEA NOT NULL CHECK (length(token_fingerprint) = 32),
endpoint_epoch BIGINT NOT NULL CHECK (endpoint_epoch > 0),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (app_profile, token_fingerprint)
);
CREATE INDEX push_gateway_installations_expiry ON push_gateway_installations (expires_at) WHERE revoked_at IS NULL;
CREATE TABLE push_gateway_delegations (
id UUID PRIMARY KEY,
installation_id UUID NOT NULL REFERENCES push_gateway_installations(id),
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
endpoint_epoch BIGINT NOT NULL CHECK (endpoint_epoch > 0),
generation BIGINT NOT NULL CHECK (generation > 0),
not_before TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (installation_id, relay_pubkey),
CHECK (not_before < expires_at)
);
CREATE INDEX push_gateway_delegations_expiry ON push_gateway_delegations (expires_at) WHERE revoked_at IS NULL;
CREATE TABLE push_gateway_endpoint_quotas (
token_fingerprint BYTEA PRIMARY KEY CHECK (length(token_fingerprint) = 32),
window_started_at TIMESTAMPTZ NOT NULL,
admitted BIGINT NOT NULL CHECK (admitted >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX push_gateway_endpoint_quotas_updated ON push_gateway_endpoint_quotas (updated_at);
CREATE TABLE push_gateway_delivery_auth_replays (
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
auth_event_id BYTEA NOT NULL CHECK (length(auth_event_id) = 32),
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (relay_pubkey, auth_event_id)
);
CREATE INDEX push_gateway_delivery_auth_replays_expiry ON push_gateway_delivery_auth_replays (expires_at);
CREATE TABLE push_gateway_delivery_request_replays (
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
request_id UUID NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (relay_pubkey, request_id)
);
CREATE INDEX push_gateway_delivery_request_replays_expiry ON push_gateway_delivery_request_replays (expires_at);
+367
View File
@@ -0,0 +1,367 @@
//! APNs envelope construction, endpoint encryption, and response classification.
use std::{sync::Mutex, time::Duration};
use async_trait::async_trait;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use p256::{
ecdsa::{signature::Signer, Signature, SigningKey},
pkcs8::DecodePrivateKey,
};
use reqwest::{
header::{AUTHORIZATION, CONTENT_TYPE},
StatusCode,
};
use serde::Deserialize;
use thiserror::Error;
use crate::model::{AppProfile, APNS_RECONNECT_PAYLOAD};
/// Sanitized delivery outcome. Raw provider bodies never cross this boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliveryOutcome {
/// APNs accepted the request (not proof of device delivery).
Accepted,
/// This endpoint generation is permanently invalid. APNs may provide the time it became invalid.
InvalidEndpoint {
/// APNs' timestamp for when the endpoint became invalid, if supplied.
unregistered_at: Option<i64>,
},
/// A bounded retry is safe. A sanitized server hint may raise the delay.
Retry {
/// Retry-After delay in seconds, clamped by the transport.
retry_after_seconds: Option<i64>,
},
/// Refresh the cached provider JWT, then retry once within normal attempt bounds.
RefreshCredential,
/// Provider credential/profile configuration is unhealthy; do not invalidate endpoints.
ConfigurationFault,
/// The locally-generated request is permanently invalid.
PermanentRequestFault,
}
/// Classify APNs status/reason without conflating provider faults with endpoints.
pub fn classify(code: u16, reason: Option<&str>, timestamp: Option<i64>) -> DeliveryOutcome {
match (code, reason) {
(200, _) => DeliveryOutcome::Accepted,
(410, Some("Unregistered")) => DeliveryOutcome::InvalidEndpoint {
unregistered_at: timestamp,
},
(400, Some("BadDeviceToken" | "DeviceTokenNotForTopic")) => {
DeliveryOutcome::InvalidEndpoint {
unregistered_at: None,
}
}
(403, Some("ExpiredProviderToken")) => DeliveryOutcome::RefreshCredential,
(403, _) | (429, Some("TooManyProviderTokenUpdates")) => {
DeliveryOutcome::ConfigurationFault
}
(429 | 500 | 503, _)
| (
_,
Some(
"IdleTimeout"
| "InternalServerError"
| "ServiceUnavailable"
| "Shutdown"
| "TooManyRequests",
),
) => DeliveryOutcome::Retry {
retry_after_seconds: None,
},
_ => DeliveryOutcome::PermanentRequestFault,
}
}
/// Closed APNs transport controls. No field can be serialized into application
/// content; the concrete transport always uses `APNS_RECONNECT_PAYLOAD`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeliveryAttempt {
pub request_id: uuid::Uuid,
pub expires_at: i64,
}
/// APNs sender abstraction for live-validation tests.
#[async_trait]
pub trait PushTransport: Send + Sync {
/// Send one durable job.
async fn send(
&self,
attempt: DeliveryAttempt,
profile: AppProfile,
endpoint: &str,
) -> DeliveryOutcome;
/// Discard a cached credential after APNs reports expiry.
fn refresh_credential(&self) {}
}
struct CachedJwt {
token: String,
issued_at: i64,
}
/// Direct HTTP/2 APNs transport using a cached ES256 provider token.
pub struct ApnsTransport {
client: reqwest::Client,
signing_key: SigningKey,
key_id: String,
team_id: String,
topic: String,
production_base_url: String,
sandbox_base_url: String,
cached_jwt: Mutex<Option<CachedJwt>>,
}
impl ApnsTransport {
/// Build a reusable APNs client from an Apple `.p8` private key.
pub fn token(p8: &[u8], key_id: &str, team_id: &str, topic: String) -> Result<Self, ApnsError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|_| ApnsError::Client)?;
Self::token_with_client(
p8,
key_id,
team_id,
topic,
client,
"https://api.push.apple.com".to_owned(),
"https://api.sandbox.push.apple.com".to_owned(),
)
}
fn token_with_client(
p8: &[u8],
key_id: &str,
team_id: &str,
topic: String,
client: reqwest::Client,
production_base_url: String,
sandbox_base_url: String,
) -> Result<Self, ApnsError> {
let pem = std::str::from_utf8(p8).map_err(|_| ApnsError::Credential)?;
let signing_key = SigningKey::from_pkcs8_pem(pem).map_err(|_| ApnsError::Credential)?;
Ok(Self {
client,
signing_key,
key_id: key_id.to_owned(),
team_id: team_id.to_owned(),
topic,
production_base_url,
sandbox_base_url,
cached_jwt: Mutex::new(None),
})
}
fn jwt(&self, now: i64) -> Result<String, ApnsError> {
let mut cached = self.cached_jwt.lock().map_err(|_| ApnsError::Credential)?;
if let Some(jwt) = cached.as_ref().filter(|jwt| now - jwt.issued_at < 50 * 60) {
return Ok(jwt.token.clone());
}
let header = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({"alg":"ES256","kid":self.key_id}))
.map_err(|_| ApnsError::Credential)?,
);
let claims = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({"iss":self.team_id,"iat":now}))
.map_err(|_| ApnsError::Credential)?,
);
let signing_input = format!("{header}.{claims}");
let signature: Signature = self.signing_key.sign(signing_input.as_bytes());
let token = format!(
"{signing_input}.{}",
URL_SAFE_NO_PAD.encode(signature.to_bytes())
);
*cached = Some(CachedJwt {
token: token.clone(),
issued_at: now,
});
Ok(token)
}
}
/// APNs transport setup failure. It intentionally carries no credential material.
#[derive(Debug, Error)]
pub enum ApnsError {
/// Invalid provider key material.
#[error("invalid APNs credential")]
Credential,
/// HTTP client setup failed.
#[error("failed to construct APNs client")]
Client,
}
#[derive(Deserialize)]
struct ApnsErrorBody {
reason: Option<String>,
timestamp: Option<i64>,
}
#[async_trait]
impl PushTransport for ApnsTransport {
async fn send(
&self,
attempt: DeliveryAttempt,
profile: AppProfile,
endpoint: &str,
) -> DeliveryOutcome {
// This is the only APNs application body in the program. It is a
// byte constant, not a serialization of the relay request, grant,
// endpoint, headers, route, provider response, or any generic JSON map.
let body = APNS_RECONNECT_PAYLOAD;
let now = chrono::Utc::now().timestamp();
let token = match self.jwt(now) {
Ok(token) => token,
Err(_) => return DeliveryOutcome::ConfigurationFault,
};
let base_url = match profile {
AppProfile::BuzzIosProduction => &self.production_base_url,
AppProfile::BuzzIosSandbox => &self.sandbox_base_url,
};
let response = self
.client
.post(format!("{base_url}/3/device/{endpoint}"))
.header(AUTHORIZATION, format!("bearer {token}"))
.header(CONTENT_TYPE, "application/json")
.header("apns-id", attempt.request_id.to_string())
.header("apns-topic", &self.topic)
.header("apns-push-type", "alert")
.header("apns-priority", "10")
.header("apns-expiration", attempt.expires_at.to_string())
.body(body)
.send()
.await;
let response = match response {
Ok(response) => response,
Err(_) => {
return DeliveryOutcome::Retry {
retry_after_seconds: None,
}
}
};
if response.status() == StatusCode::OK {
return DeliveryOutcome::Accepted;
}
let code = response.status().as_u16();
let retry_after = response
.headers()
.get("retry-after")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<i64>().ok())
.map(|seconds| seconds.clamp(1, 3600));
let detail = response.json::<ApnsErrorBody>().await.ok();
let timestamp = detail.as_ref().and_then(|d| d.timestamp);
match classify(
code,
detail.as_ref().and_then(|d| d.reason.as_deref()),
timestamp,
) {
DeliveryOutcome::Retry { .. } => DeliveryOutcome::Retry {
retry_after_seconds: retry_after,
},
outcome => outcome,
}
}
fn refresh_credential(&self) {
if let Ok(mut cached) = self.cached_jwt.lock() {
*cached = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Bytes, extract::State, http::StatusCode, routing::post, Router};
use p256::pkcs8::{EncodePrivateKey, LineEnding};
use std::sync::Arc;
async fn capture_body(
State(bodies): State<Arc<Mutex<Vec<Vec<u8>>>>>,
body: Bytes,
) -> StatusCode {
bodies.lock().unwrap().push(body.to_vec());
StatusCode::OK
}
#[tokio::test]
async fn real_outbound_http_body_is_the_exact_constant_for_every_attempt() {
let bodies = Arc::new(Mutex::new(Vec::new()));
let app = Router::new()
.route("/3/device/{endpoint}", post(capture_body))
.with_state(bodies.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let signing_key = SigningKey::from_slice(&[7; 32]).unwrap();
let pem = signing_key.to_pkcs8_pem(LineEnding::LF).unwrap();
let transport = ApnsTransport::token_with_client(
pem.as_bytes(),
"kid",
"team",
"app.topic".to_owned(),
reqwest::Client::new(),
base_url.clone(),
base_url,
)
.unwrap();
for (request_id, expires_at, profile, endpoint) in [
(
uuid::Uuid::nil(),
1,
AppProfile::BuzzIosProduction,
"00".repeat(32),
),
(
uuid::Uuid::max(),
i64::MAX,
AppProfile::BuzzIosSandbox,
"ff".repeat(32),
),
] {
assert_eq!(
transport
.send(
DeliveryAttempt {
request_id,
expires_at,
},
profile,
&endpoint,
)
.await,
DeliveryOutcome::Accepted
);
}
let captured = bodies.lock().unwrap();
assert_eq!(captured.len(), 2);
assert!(captured
.iter()
.all(|body| body.as_slice() == APNS_RECONNECT_PAYLOAD));
}
#[test]
fn response_classes_do_not_massacre_endpoints_on_provider_faults() {
assert_eq!(
classify(410, Some("Unregistered"), Some(7)),
DeliveryOutcome::InvalidEndpoint {
unregistered_at: Some(7)
}
);
assert_eq!(
classify(403, Some("InvalidProviderToken"), None),
DeliveryOutcome::ConfigurationFault
);
assert_eq!(
classify(429, Some("TooManyRequests"), None),
DeliveryOutcome::Retry {
retry_after_seconds: None
}
);
assert_eq!(
classify(400, Some("BadTopic"), None),
DeliveryOutcome::PermanentRequestFault
);
}
}
+140
View File
@@ -0,0 +1,140 @@
//! Narrow App Attest verification boundary. Production enrollment accepts only
//! Apple production AAGUID material; unsupported devices have no bypass path.
use appattest::{assertion::Assertion, attestation::Attestation};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use byteorder::{BigEndian, ByteOrder};
use sha2::{Digest, Sha256};
use thiserror::Error;
const MAX_ATTESTATION_BYTES: usize = 16 * 1024;
const MAX_ASSERTION_BYTES: usize = 1024;
const APPLE_APP_ATTEST_ROOT_PEM_SHA256: [u8; 32] = [
0xc7, 0x78, 0xd0, 0x9a, 0xc3, 0x41, 0xf7, 0xfd, 0x9f, 0x8f, 0x3b, 0x19, 0xe2, 0xb8, 0x15, 0xaf,
0x6a, 0xed, 0x4a, 0xd4, 0x49, 0x0e, 0x1e, 0x92, 0xc0, 0x5c, 0xb3, 0x55, 0x21, 0x2a, 0x50, 0x13,
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedAttestation {
pub key_id: Vec<u8>,
pub public_key: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VerifiedAssertion {
pub counter: u32,
}
#[derive(Debug, Error)]
pub enum AppAttestError {
#[error("invalid app attestation or assertion")]
Invalid,
}
#[derive(Clone)]
pub struct AppAttestVerifier {
app_id: String,
apple_root_cert_pem: Vec<u8>,
}
impl AppAttestVerifier {
pub fn new(app_id: String, apple_root_cert_pem: Vec<u8>) -> Result<Self, AppAttestError> {
if app_id.is_empty()
|| Sha256::digest(&apple_root_cert_pem).as_slice() != APPLE_APP_ATTEST_ROOT_PEM_SHA256
{
return Err(AppAttestError::Invalid);
}
Ok(Self {
app_id,
apple_root_cert_pem,
})
}
/// `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.
pub fn verify_attestation(
&self,
attestation_b64: &str,
key_id_b64: &str,
client_data: &[u8],
) -> Result<VerifiedAttestation, AppAttestError> {
let cbor = STANDARD
.decode(attestation_b64)
.map_err(|_| AppAttestError::Invalid)?;
if cbor.is_empty() || cbor.len() > MAX_ATTESTATION_BYTES {
return Err(AppAttestError::Invalid);
}
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
.verify(
challenge,
&self.app_id,
key_id_b64,
&self.apple_root_cert_pem,
)
.map_err(|_| AppAttestError::Invalid)?;
let key_id = STANDARD
.decode(key_id_b64)
.map_err(|_| AppAttestError::Invalid)?;
if key_id.len() != 32 {
return Err(AppAttestError::Invalid);
}
Ok(VerifiedAttestation {
key_id,
public_key: public_key.to_vec(),
})
}
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> {
let cbor = STANDARD
.decode(assertion_b64)
.map_err(|_| AppAttestError::Invalid)?;
if cbor.is_empty() || cbor.len() > MAX_ASSERTION_BYTES {
return Err(AppAttestError::Invalid);
}
let counter = assertion_counter(&cbor)?;
let client_data_hash = Sha256::digest(client_data);
Assertion::from_assertion(&cbor)
.map_err(|_| AppAttestError::Invalid)?
.verify(
client_data_hash,
challenge,
&self.app_id,
public_key,
previous_counter,
stored_challenge,
)
.map_err(|_| AppAttestError::Invalid)?;
Ok(VerifiedAssertion { counter })
}
}
/// 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.
fn assertion_counter(cbor: &[u8]) -> Result<u32, AppAttestError> {
let mut d = minicbor::Decoder::new(cbor);
let count = d
.map()
.map_err(|_| AppAttestError::Invalid)?
.ok_or(AppAttestError::Invalid)?;
let mut auth = None;
for _ in 0..count {
let k = d.str().map_err(|_| AppAttestError::Invalid)?;
match k {
"authenticatorData" => auth = Some(d.bytes().map_err(|_| AppAttestError::Invalid)?),
"signature" => {
d.bytes().map_err(|_| AppAttestError::Invalid)?;
}
_ => return Err(AppAttestError::Invalid),
}
}
let auth = auth
.filter(|a| a.len() == 37)
.ok_or(AppAttestError::Invalid)?;
Ok(BigEndian::read_u32(&auth[33..37]))
}
+576
View File
@@ -0,0 +1,576 @@
//! Durable installation authority and relay delegation state machine.
//!
//! App Attest authenticates the app instance and exact request transcript. It
//! does not prove an Apple-issued binding between that key and the APNs token;
//! accepting the directly submitted token is the protocol's explicit bootstrap
//! assumption.
use crate::model::AppProfile;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Challenge {
pub id: Uuid,
pub value: [u8; 32],
pub expires_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewInstallation {
pub id: Uuid,
pub app_attest_key_id: Vec<u8>,
pub app_attest_public_key: Vec<u8>,
pub assertion_counter: u32,
pub profile: AppProfile,
pub token_ciphertext: Vec<u8>,
pub token_fingerprint: [u8; 32],
pub endpoint_epoch: i64,
pub expires_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Installation {
pub id: Uuid,
pub app_attest_key_id: Vec<u8>,
pub app_attest_public_key: Vec<u8>,
pub assertion_counter: u32,
pub profile: AppProfile,
pub token_ciphertext: Vec<u8>,
pub token_fingerprint: [u8; 32],
pub endpoint_epoch: i64,
pub expires_at: i64,
pub revoked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Delegation {
pub id: Uuid,
pub installation_id: Uuid,
pub relay_pubkey: String,
pub endpoint_epoch: i64,
pub generation: i64,
pub not_before: i64,
pub expires_at: i64,
pub revoked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeliveryAuthority {
pub delegation_id: Uuid,
pub installation_id: Uuid,
pub relay_pubkey: String,
pub profile: AppProfile,
pub token_ciphertext: Vec<u8>,
pub endpoint_epoch: i64,
pub generation: i64,
pub expires_at: i64,
}
#[derive(Debug)]
pub struct DeliveryPermit {
pub authority: DeliveryAuthority,
pub relay_pubkey: String,
pub request_id: Uuid,
}
impl DeliveryPermit {
pub fn new(authority: DeliveryAuthority, relay_pubkey: String, request_id: Uuid) -> Self {
Self {
authority,
relay_pubkey,
request_id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliveryDisposition {
Terminal,
Retryable,
}
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum AuthorityError {
#[error("authority state rejected the request")]
Rejected,
#[error("authority store unavailable")]
Unavailable,
}
/// Every mutating method is an atomic store operation. Implementations must
/// serialize installation assertion counters and delegation generations.
#[async_trait]
pub trait AuthorityStore: Send + Sync {
/// Readiness must fail closed when durable authority cannot participate.
async fn ready(&self) -> Result<(), AuthorityError>;
async fn put_challenge(&self, challenge: Challenge) -> Result<(), AuthorityError>;
async fn consume_challenge(
&self,
id: Uuid,
value: [u8; 32],
now: i64,
) -> Result<(), AuthorityError>;
async fn create_installation(
&self,
installation: NewInstallation,
) -> Result<(), AuthorityError>;
async fn installation(&self, id: Uuid, now: i64) -> Result<Installation, AuthorityError>;
async fn advance_assertion_counter(
&self,
installation_id: Uuid,
previous: u32,
next: u32,
) -> Result<(), AuthorityError>;
async fn upsert_delegation(&self, delegation: Delegation) -> Result<(), AuthorityError>;
async fn rotate_endpoint(
&self,
installation_id: Uuid,
expected_epoch: i64,
new_epoch: i64,
token_ciphertext: Vec<u8>,
token_fingerprint: [u8; 32],
) -> Result<(), AuthorityError>;
async fn revoke_delegation(
&self,
installation_id: Uuid,
relay_pubkey: &str,
new_generation: i64,
) -> Result<(), AuthorityError>;
async fn revoke_installation(
&self,
installation_id: Uuid,
expected_epoch: i64,
new_epoch: i64,
) -> Result<(), AuthorityError>;
/// Atomically validate and lock installation then delegation authority,
/// reserve quota/replay state, and commit. That durable commit is the
/// delivery send-begin linearization point seen by revocation.
#[allow(clippy::too_many_arguments)]
async fn authorize_delivery(
&self,
delegation_id: Uuid,
relay_pubkey: &str,
endpoint_epoch: i64,
generation: i64,
event_id: &str,
request_id: Uuid,
request_expires_at: i64,
quota_window_seconds: i64,
quota_max_deliveries: i64,
now: i64,
) -> Result<DeliveryPermit, AuthorityError>;
/// Retain terminal request ids; release retryable request ids while always
/// retaining the one-use auth event admitted by `authorize_delivery`.
async fn finish_delivery(
&self,
permit: DeliveryPermit,
disposition: DeliveryDisposition,
) -> Result<(), AuthorityError>;
/// Delete only data whose safety retention window has elapsed.
async fn reap_expired(&self, now: i64) -> Result<(), AuthorityError>;
}
#[derive(Default)]
struct MemoryState {
challenges: HashMap<Uuid, Challenge>,
installations: HashMap<Uuid, Installation>,
token_owners: HashMap<(AppProfile, [u8; 32]), Uuid>,
delegations: HashMap<(Uuid, String), Delegation>,
delegation_ids: HashMap<Uuid, (Uuid, String)>,
delivery_auth_replays: HashMap<(String, String), i64>,
delivery_request_replays: HashMap<(String, Uuid), i64>,
endpoint_quotas: HashMap<[u8; 32], (i64, i64)>,
}
/// Executable reference store used by conformance tests. Production uses the
/// PostgreSQL implementation; this lock deliberately gives the model a single
/// linearization point for every authority transition.
#[derive(Default)]
pub struct MemoryAuthorityStore(Mutex<MemoryState>);
#[async_trait]
impl AuthorityStore for MemoryAuthorityStore {
async fn ready(&self) -> Result<(), AuthorityError> {
self.0
.lock()
.map(|_| ())
.map_err(|_| AuthorityError::Unavailable)
}
async fn put_challenge(&self, challenge: Challenge) -> Result<(), AuthorityError> {
let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?;
if s.challenges.insert(challenge.id, challenge).is_some() {
return Err(AuthorityError::Rejected);
}
Ok(())
}
async fn consume_challenge(
&self,
id: Uuid,
value: [u8; 32],
now: i64,
) -> Result<(), AuthorityError> {
let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?;
let challenge = s.challenges.remove(&id).ok_or(AuthorityError::Rejected)?;
if challenge.value != value || challenge.expires_at < now {
return Err(AuthorityError::Rejected);
}
Ok(())
}
async fn create_installation(&self, n: NewInstallation) -> Result<(), AuthorityError> {
let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?;
let token_key = (n.profile, n.token_fingerprint);
if s.installations.contains_key(&n.id) || s.token_owners.contains_key(&token_key) {
// Token possession alone never supersedes a live installation.
return Err(AuthorityError::Rejected);
}
s.token_owners.insert(token_key, n.id);
s.installations.insert(
n.id,
Installation {
id: n.id,
app_attest_key_id: n.app_attest_key_id,
app_attest_public_key: n.app_attest_public_key,
assertion_counter: n.assertion_counter,
profile: n.profile,
token_ciphertext: n.token_ciphertext,
token_fingerprint: n.token_fingerprint,
endpoint_epoch: n.endpoint_epoch,
expires_at: n.expires_at,
revoked: false,
},
);
Ok(())
}
async fn installation(&self, id: Uuid, now: i64) -> Result<Installation, AuthorityError> {
let s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?;
let i = s
.installations
.get(&id)
.filter(|i| !i.revoked && i.expires_at >= now)
.ok_or(AuthorityError::Rejected)?;
Ok(i.clone())
}
async fn advance_assertion_counter(
&self,
id: Uuid,
previous: u32,
next: u32,
) -> Result<(), AuthorityError> {
if next <= previous {
return Err(AuthorityError::Rejected);
}
let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?;
let i = s
.installations
.get_mut(&id)
.ok_or(AuthorityError::Rejected)?;
if i.revoked || i.assertion_counter != previous {
return Err(AuthorityError::Rejected);
}
i.assertion_counter = next;
Ok(())
}
async fn upsert_delegation(&self, d: Delegation) -> Result<(), AuthorityError> {
let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?;
let i = s
.installations
.get(&d.installation_id)
.ok_or(AuthorityError::Rejected)?;
if i.revoked
|| i.endpoint_epoch != d.endpoint_epoch
|| d.generation < 1
|| d.not_before >= d.expires_at
|| d.expires_at > i.expires_at
{
return Err(AuthorityError::Rejected);
}
let key = (d.installation_id, d.relay_pubkey.clone());
if s.delegations
.get(&key)
.is_some_and(|old| d.generation <= old.generation)
|| s.delegation_ids.contains_key(&d.id)
{
return Err(AuthorityError::Rejected);
}
s.delegation_ids.insert(d.id, key.clone());
s.delegations.insert(key, d);
Ok(())
}
async fn rotate_endpoint(
&self,
id: Uuid,
expected: i64,
new: i64,
ciphertext: Vec<u8>,
fingerprint: [u8; 32],
) -> Result<(), AuthorityError> {
if new != expected.checked_add(1).ok_or(AuthorityError::Rejected)? {
return Err(AuthorityError::Rejected);
}
let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?;
let (profile, old_fingerprint) = {
let i = s.installations.get(&id).ok_or(AuthorityError::Rejected)?;
if i.revoked || i.endpoint_epoch != expected {
return Err(AuthorityError::Rejected);
}
(i.profile, i.token_fingerprint)
};
let token_key = (profile, fingerprint);
if s.token_owners
.get(&token_key)
.is_some_and(|owner| *owner != id)
{
return Err(AuthorityError::Rejected);
}
s.token_owners.remove(&(profile, old_fingerprint));
s.token_owners.insert(token_key, id);
let i = s
.installations
.get_mut(&id)
.ok_or(AuthorityError::Rejected)?;
i.endpoint_epoch = new;
i.token_ciphertext = ciphertext;
i.token_fingerprint = fingerprint;
Ok(())
}
async fn revoke_delegation(
&self,
id: Uuid,
relay: &str,
generation: i64,
) -> Result<(), AuthorityError> {
let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?;
let key = (id, relay.to_owned());
let old = s
.delegations
.get_mut(&key)
.ok_or(AuthorityError::Rejected)?;
if generation <= old.generation {
return Err(AuthorityError::Rejected);
}
old.generation = generation;
old.revoked = true;
Ok(())
}
async fn revoke_installation(
&self,
id: Uuid,
expected: i64,
new: i64,
) -> Result<(), AuthorityError> {
if new != expected.checked_add(1).ok_or(AuthorityError::Rejected)? {
return Err(AuthorityError::Rejected);
}
let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?;
let i = s
.installations
.get_mut(&id)
.ok_or(AuthorityError::Rejected)?;
if i.revoked || i.endpoint_epoch != expected {
return Err(AuthorityError::Rejected);
}
i.endpoint_epoch = new;
i.revoked = true;
Ok(())
}
async fn authorize_delivery(
&self,
delegation_id: Uuid,
relay: &str,
epoch: i64,
generation: i64,
event_id: &str,
request_id: Uuid,
request_expires_at: i64,
quota_window_seconds: i64,
quota_max_deliveries: i64,
now: i64,
) -> Result<DeliveryPermit, AuthorityError> {
let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?;
let key = s
.delegation_ids
.get(&delegation_id)
.cloned()
.ok_or(AuthorityError::Rejected)?;
let d = s.delegations.get(&key).ok_or(AuthorityError::Rejected)?;
let i = s
.installations
.get(&d.installation_id)
.ok_or(AuthorityError::Rejected)?;
if d.revoked
|| i.revoked
|| d.id != delegation_id
|| d.relay_pubkey != relay
|| d.endpoint_epoch != epoch
|| d.generation != generation
|| i.endpoint_epoch != epoch
|| now < d.not_before
|| now > d.expires_at
|| now > i.expires_at
|| request_expires_at < now
|| request_expires_at > d.expires_at
{
return Err(AuthorityError::Rejected);
}
let authority = DeliveryAuthority {
delegation_id,
installation_id: i.id,
relay_pubkey: relay.to_owned(),
profile: i.profile,
token_ciphertext: i.token_ciphertext.clone(),
endpoint_epoch: epoch,
generation,
expires_at: d.expires_at,
};
let fingerprint = i.token_fingerprint;
let auth_key = (relay.to_owned(), event_id.to_owned());
let request_key = (relay.to_owned(), request_id);
if s.delivery_auth_replays.contains_key(&auth_key)
|| s.delivery_request_replays.contains_key(&request_key)
{
return Err(AuthorityError::Rejected);
}
let quota = s.endpoint_quotas.entry(fingerprint).or_insert((now, 0));
if now.saturating_sub(quota.0) >= quota_window_seconds {
*quota = (now, 0);
}
if quota.1 >= quota_max_deliveries {
return Err(AuthorityError::Rejected);
}
quota.1 += 1;
s.delivery_auth_replays.insert(auth_key, request_expires_at);
s.delivery_request_replays
.insert(request_key, request_expires_at);
Ok(DeliveryPermit::new(authority, relay.to_owned(), request_id))
}
async fn finish_delivery(
&self,
permit: DeliveryPermit,
disposition: DeliveryDisposition,
) -> Result<(), AuthorityError> {
if disposition == DeliveryDisposition::Retryable {
self.0
.lock()
.map_err(|_| AuthorityError::Unavailable)?
.delivery_request_replays
.remove(&(permit.relay_pubkey, permit.request_id));
}
Ok(())
}
async fn reap_expired(&self, now: i64) -> Result<(), AuthorityError> {
let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?;
s.challenges
.retain(|_, challenge| challenge.expires_at >= now);
s.delivery_auth_replays
.retain(|_, expires_at| *expires_at >= now);
s.delivery_request_replays
.retain(|_, expires_at| *expires_at >= now);
s.endpoint_quotas
.retain(|_, (started_at, _)| now.saturating_sub(*started_at) < 86_400);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn admitted(
store: &MemoryAuthorityStore,
event: &str,
request: Uuid,
) -> Result<DeliveryPermit, AuthorityError> {
store
.authorize_delivery(
Uuid::from_u128(2),
&"11".repeat(32),
1,
1,
event,
request,
1_100,
60,
10,
1_000,
)
.await
}
async fn store() -> MemoryAuthorityStore {
let store = MemoryAuthorityStore::default();
store
.create_installation(NewInstallation {
id: Uuid::from_u128(1),
app_attest_key_id: vec![1],
app_attest_public_key: vec![2; 33],
assertion_counter: 0,
profile: AppProfile::BuzzIosProduction,
token_ciphertext: vec![3],
token_fingerprint: [4; 32],
endpoint_epoch: 1,
expires_at: 2_000,
})
.await
.unwrap();
store
.upsert_delegation(Delegation {
id: Uuid::from_u128(2),
installation_id: Uuid::from_u128(1),
relay_pubkey: "11".repeat(32),
endpoint_epoch: 1,
generation: 1,
not_before: 900,
expires_at: 1_500,
revoked: false,
})
.await
.unwrap();
store
}
#[tokio::test]
async fn retry_releases_request_id_but_burns_auth_event() {
let store = store().await;
let request = Uuid::new_v4();
let first_event = "22".repeat(32);
let permit = admitted(&store, &first_event, request).await.unwrap();
store
.finish_delivery(permit, DeliveryDisposition::Retryable)
.await
.unwrap();
assert!(admitted(&store, &first_event, Uuid::new_v4())
.await
.is_err());
admitted(&store, &"33".repeat(32), request)
.await
.expect("fresh auth event may retry stable request id");
}
#[tokio::test]
async fn terminal_outcome_burns_request_id() {
let store = store().await;
let request = Uuid::new_v4();
let permit = admitted(&store, &"22".repeat(32), request).await.unwrap();
store
.finish_delivery(permit, DeliveryDisposition::Terminal)
.await
.unwrap();
assert!(admitted(&store, &"33".repeat(32), request).await.is_err());
}
}
+297
View File
@@ -0,0 +1,297 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
use std::{
collections::{HashMap, HashSet},
net::SocketAddr,
path::PathBuf,
};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyConfig {
pub id: String,
pub key: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct Config {
pub bind_addr: SocketAddr,
pub health_addr: SocketAddr,
pub public_delivery_url: url::Url,
pub max_grant_lifetime_seconds: i64,
pub max_installation_lifetime_seconds: i64,
pub endpoint_quota_window_seconds: i64,
pub endpoint_quota_max_deliveries: i64,
pub enabled_profiles: HashSet<crate::model::AppProfile>,
pub database_url: String,
pub app_attest_app_id: String,
pub app_attest_root_cert_path: PathBuf,
/// 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
/// externally presented delivery capabilities.
pub token_keys: Vec<KeyConfig>,
pub apns_key_path: PathBuf,
pub apns_key_id: String,
pub apns_team_id: String,
pub apns_topic: String,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("missing required environment variable {0}")]
Missing(&'static str),
#[error("invalid environment variable {0}")]
Invalid(&'static str),
}
fn parse_keyring(
e: &HashMap<String, String>,
variable: &'static str,
) -> Result<Vec<KeyConfig>, ConfigError> {
let value = e
.get(variable)
.map(String::as_str)
.filter(|value| !value.is_empty())
.ok_or(ConfigError::Missing(variable))?;
let keys = value
.split(',')
.map(|entry| {
let (id, encoded) = entry
.split_once(':')
.filter(|(id, encoded)| !id.is_empty() && !encoded.is_empty())
.ok_or(ConfigError::Invalid(variable))?;
let key = STANDARD
.decode(encoded)
.map_err(|_| ConfigError::Invalid(variable))?;
if key.len() != 32 {
return Err(ConfigError::Invalid(variable));
}
Ok(KeyConfig {
id: id.to_owned(),
key,
})
})
.collect::<Result<Vec<_>, _>>()?;
if keys.is_empty() {
return Err(ConfigError::Invalid(variable));
}
Ok(keys)
}
impl Config {
pub fn from_env() -> Result<Self, ConfigError> {
Self::from_map(&std::env::vars().collect())
}
pub fn from_map(e: &HashMap<String, String>) -> Result<Self, ConfigError> {
fn req<'a>(
e: &'a HashMap<String, String>,
k: &'static str,
) -> Result<&'a str, ConfigError> {
e.get(k)
.map(String::as_str)
.filter(|v| !v.is_empty())
.ok_or(ConfigError::Missing(k))
}
let grant_keys = parse_keyring(e, "BUZZ_PUSH_GRANT_KEYS")?;
let token_keys = parse_keyring(e, "BUZZ_PUSH_TOKEN_KEYS")?;
if grant_keys.iter().any(|grant| {
token_keys
.iter()
.any(|token| grant.id == token.id || grant.key == token.key)
}) {
return Err(ConfigError::Invalid("BUZZ_PUSH_TOKEN_KEYS"));
}
let public_delivery_url = req(e, "BUZZ_PUSH_PUBLIC_DELIVERY_URL")?
.parse::<url::Url>()
.map_err(|_| ConfigError::Invalid("BUZZ_PUSH_PUBLIC_DELIVERY_URL"))?;
if public_delivery_url.scheme() != "https"
|| public_delivery_url.host_str() != Some("push.buzz.xyz")
|| public_delivery_url.port().is_some()
|| public_delivery_url.path() != "/v1/deliveries/apns"
|| public_delivery_url.query().is_some()
|| public_delivery_url.fragment().is_some()
|| !public_delivery_url.username().is_empty()
|| public_delivery_url.password().is_some()
{
return Err(ConfigError::Invalid("BUZZ_PUSH_PUBLIC_DELIVERY_URL"));
}
let max_grant_lifetime_seconds = req(e, "BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS")?
.parse::<i64>()
.ok()
.filter(|seconds| (1..=31_536_000).contains(seconds))
.ok_or(ConfigError::Invalid("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS"))?;
let max_installation_lifetime_seconds = e
.get("BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS")
.map(String::as_str)
.unwrap_or("7776000")
.parse::<i64>()
.ok()
.filter(|seconds| (1..=31_536_000).contains(seconds))
.ok_or(ConfigError::Invalid(
"BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS",
))?;
let bounded_positive = |key: &'static str, default: i64, max: i64| {
e.get(key)
.map(String::as_str)
.unwrap_or("")
.parse::<i64>()
.ok()
.or((!e.contains_key(key)).then_some(default))
.filter(|value| (1..=max).contains(value))
.ok_or(ConfigError::Invalid(key))
};
let endpoint_quota_window_seconds =
bounded_positive("BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS", 10, 86_400)?;
let endpoint_quota_max_deliveries =
bounded_positive("BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES", 10, 10_000)?;
let enabled_profiles = req(e, "BUZZ_PUSH_ENABLED_PROFILES")?
.split(',')
.map(|profile| match profile {
"buzz-ios-production" => Ok(crate::model::AppProfile::BuzzIosProduction),
"buzz-ios-sandbox" => Ok(crate::model::AppProfile::BuzzIosSandbox),
_ => Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")),
})
.collect::<Result<HashSet<_>, _>>()?;
if enabled_profiles.is_empty() {
return Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES"));
}
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"))?,
public_delivery_url,
max_grant_lifetime_seconds,
max_installation_lifetime_seconds,
endpoint_quota_window_seconds,
endpoint_quota_max_deliveries,
enabled_profiles,
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(),
grant_keys,
token_keys,
apns_key_path: req(e, "BUZZ_PUSH_APNS_KEY_PATH")?.into(),
apns_key_id: req(e, "BUZZ_PUSH_APNS_KEY_ID")?.to_owned(),
apns_team_id: req(e, "BUZZ_PUSH_APNS_TEAM_ID")?.to_owned(),
apns_topic: req(e, "BUZZ_PUSH_APNS_TOPIC")?.to_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> HashMap<String, String> {
HashMap::from([
(
"BUZZ_PUSH_GRANT_KEYS".into(),
format!(
"current:{},old:{}",
STANDARD.encode([1; 32]),
STANDARD.encode([2; 32])
),
),
(
"BUZZ_PUSH_TOKEN_KEYS".into(),
format!(
"current-token:{},old-token:{}",
STANDARD.encode([3; 32]),
STANDARD.encode([4; 32])
),
),
(
"BUZZ_PUSH_PUBLIC_DELIVERY_URL".into(),
"https://push.buzz.xyz/v1/deliveries/apns".into(),
),
(
"BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS".into(),
"2592000".into(),
),
(
"BUZZ_PUSH_ENABLED_PROFILES".into(),
"buzz-ios-production".into(),
),
(
"DATABASE_URL".into(),
"postgres://buzz:test@localhost/buzz".into(),
),
("BUZZ_PUSH_APP_ATTEST_APP_ID".into(), "TEAM.app".into()),
(
"BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH".into(),
"/apple-root.pem".into(),
),
("BUZZ_PUSH_APNS_KEY_PATH".into(), "/key.p8".into()),
("BUZZ_PUSH_APNS_KEY_ID".into(), "key".into()),
("BUZZ_PUSH_APNS_TEAM_ID".into(), "team".into()),
("BUZZ_PUSH_APNS_TOPIC".into(), "app".into()),
])
}
#[test]
fn keyrings_preserve_current_then_predecessor_order_and_are_independent() {
let config = Config::from_map(&base()).unwrap();
assert_eq!(config.grant_keys[0].id, "current");
assert_eq!(config.grant_keys[1].id, "old");
assert_eq!(config.token_keys[0].id, "current-token");
assert_eq!(config.token_keys[1].id, "old-token");
assert_ne!(config.grant_keys[0].key, config.token_keys[0].key);
}
#[test]
fn malformed_security_configuration_fails_startup() {
for (key, value) in [
(
"BUZZ_PUSH_PUBLIC_DELIVERY_URL",
"http://push.example/v1/deliveries/apns",
),
(
"BUZZ_PUSH_PUBLIC_DELIVERY_URL",
"https://push.example/v1/deliveries/apns",
),
("BUZZ_PUSH_APP_ATTEST_APP_ID", ""),
("BUZZ_PUSH_ENABLED_PROFILES", "unknown-profile"),
("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"),
("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "31536001"),
("BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS", "0"),
] {
let mut env = base();
env.insert(key.into(), value.into());
assert!(Config::from_map(&env).is_err(), "accepted {key}={value}");
}
}
#[test]
fn cross_keyring_id_or_material_reuse_fails_startup() {
for token_keys in [
format!("current:{}", STANDARD.encode([9; 32])),
format!("other:{}", STANDARD.encode([1; 32])),
] {
let mut env = base();
env.insert("BUZZ_PUSH_TOKEN_KEYS".into(), token_keys);
assert!(Config::from_map(&env).is_err());
}
}
#[test]
fn malformed_or_empty_keyrings_fail_startup() {
for (variable, value) in [
("BUZZ_PUSH_GRANT_KEYS", ""),
("BUZZ_PUSH_GRANT_KEYS", "missing_separator"),
("BUZZ_PUSH_GRANT_KEYS", "id:bad-base64"),
("BUZZ_PUSH_TOKEN_KEYS", ""),
("BUZZ_PUSH_TOKEN_KEYS", "missing_separator"),
("BUZZ_PUSH_TOKEN_KEYS", "id:bad-base64"),
] {
let mut env = base();
env.insert(variable.into(), value.into());
assert!(Config::from_map(&env).is_err());
}
}
}
+240
View File
@@ -0,0 +1,240 @@
//! Authenticated, expiring endpoint grants. The gateway is stateless: relays
//! retain the opaque ciphertext and present it on each delivery attempt.
use std::collections::{HashMap, HashSet};
use crate::model::{EndpointGrant, MAX_GRANT_BYTES};
use aes_gcm::{
aead::{rand_core::RngCore, Aead, KeyInit, OsRng},
Aes256Gcm, Nonce,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use thiserror::Error;
const AAD_PREFIX: &[u8] = b"buzz-stateful-delivery-capability-v1:";
const MAX_KEY_ID_BYTES: usize = 32;
#[derive(Clone)]
pub struct GrantKey {
id: String,
cipher: Aes256Gcm,
}
#[derive(Clone)]
pub struct GrantKeyring {
current: GrantKey,
predecessors: HashMap<String, GrantKey>,
}
#[derive(Debug, Error)]
pub enum GrantError {
#[error("invalid endpoint grant")]
Invalid,
#[error("duplicate grant key id")]
DuplicateKeyId,
#[error("grant keyring is empty")]
EmptyKeyring,
}
impl GrantKey {
pub fn new(id: impl Into<String>, key: &[u8]) -> Result<Self, GrantError> {
let id = id.into();
if id.is_empty()
|| id.len() > MAX_KEY_ID_BYTES
|| !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
{
return Err(GrantError::Invalid);
}
Ok(Self {
id,
cipher: Aes256Gcm::new_from_slice(key).map_err(|_| GrantError::Invalid)?,
})
}
fn aad(&self) -> Vec<u8> {
[AAD_PREFIX, self.id.as_bytes()].concat()
}
fn seal(&self, grant: &EndpointGrant) -> Result<String, GrantError> {
let plaintext = serde_json::to_vec(grant).map_err(|_| GrantError::Invalid)?;
let mut nonce = [0u8; 12];
OsRng.fill_bytes(&mut nonce);
let mut encrypted = nonce.to_vec();
encrypted.extend(
self.cipher
.encrypt(
Nonce::from_slice(&nonce),
aes_gcm::aead::Payload {
msg: &plaintext,
aad: &self.aad(),
},
)
.map_err(|_| GrantError::Invalid)?,
);
let encoded = format!("{}.{}", self.id, URL_SAFE_NO_PAD.encode(encrypted));
if encoded.len() > MAX_GRANT_BYTES {
return Err(GrantError::Invalid);
}
Ok(encoded)
}
fn open(&self, encoded: &str) -> Result<EndpointGrant, GrantError> {
let (id, payload) = encoded.split_once('.').ok_or(GrantError::Invalid)?;
if id != self.id {
return Err(GrantError::Invalid);
}
let bytes = URL_SAFE_NO_PAD
.decode(payload)
.map_err(|_| GrantError::Invalid)?;
if bytes.len() < 13 {
return Err(GrantError::Invalid);
}
let plaintext = self
.cipher
.decrypt(
Nonce::from_slice(&bytes[..12]),
aes_gcm::aead::Payload {
msg: &bytes[12..],
aad: &self.aad(),
},
)
.map_err(|_| GrantError::Invalid)?;
crate::strict_json::from_slice(&plaintext).map_err(|_| GrantError::Invalid)
}
}
impl GrantKeyring {
/// Build a keyring ordered current key first, then decrypt-only predecessors.
pub fn new(keys: Vec<GrantKey>) -> Result<Self, GrantError> {
let mut keys = keys.into_iter();
let current = keys.next().ok_or(GrantError::EmptyKeyring)?;
let predecessor_keys: Vec<_> = keys.collect();
let mut ids = HashSet::with_capacity(predecessor_keys.len() + 1);
if !ids.insert(current.id.as_str())
|| predecessor_keys
.iter()
.any(|key| !ids.insert(key.id.as_str()))
{
return Err(GrantError::DuplicateKeyId);
}
let predecessors = predecessor_keys
.into_iter()
.map(|key| (key.id.clone(), key))
.collect();
Ok(Self {
current,
predecessors,
})
}
/// Mint with the current key only.
pub fn issue(&self, grant: &EndpointGrant) -> Result<String, GrantError> {
self.current.seal(grant)
}
/// Open with the key selected by the authenticated envelope key id.
pub fn open(&self, encoded: &str) -> Result<EndpointGrant, GrantError> {
if encoded.len() > MAX_GRANT_BYTES {
return Err(GrantError::Invalid);
}
let (id, _) = encoded.split_once('.').ok_or(GrantError::Invalid)?;
if id == self.current.id {
return self.current.open(encoded);
}
self.predecessors
.get(id)
.ok_or(GrantError::Invalid)?
.open(encoded)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::*;
fn grant() -> EndpointGrant {
EndpointGrant {
v: 1,
delegation_id: uuid::Uuid::nil(),
relay_pubkey: "11".repeat(32),
app_profile: AppProfile::BuzzIosProduction,
endpoint_epoch: 1,
generation: 2,
expires_at: 99,
}
}
#[test]
fn current_issues_and_predecessor_opens_after_rotation() {
let old = GrantKeyring::new(vec![GrantKey::new("old", &[7; 32]).unwrap()]).unwrap();
let sealed_old = old.issue(&grant()).unwrap();
let without_old =
GrantKeyring::new(vec![GrantKey::new("current", &[8; 32]).unwrap()]).unwrap();
assert!(without_old.open(&sealed_old).is_err());
let rotated = GrantKeyring::new(vec![
GrantKey::new("current", &[8; 32]).unwrap(),
GrantKey::new("old", &[7; 32]).unwrap(),
])
.unwrap();
let sealed_current = rotated.issue(&grant()).unwrap();
assert!(sealed_current.starts_with("current."));
assert_eq!(rotated.open(&sealed_old).unwrap(), grant());
assert_eq!(rotated.open(&sealed_current).unwrap(), grant());
}
#[test]
fn configured_route_id_is_authenticated_even_when_keys_match() {
let ring = GrantKeyring::new(vec![
GrantKey::new("current", &[7; 32]).unwrap(),
GrantKey::new("previous", &[7; 32]).unwrap(),
])
.unwrap();
let route_tampered = ring
.issue(&grant())
.unwrap()
.replacen("current.", "previous.", 1);
assert!(ring.open(&route_tampered).is_err());
}
#[test]
fn complete_envelope_length_is_bounded_on_issue_and_open() {
let ring = GrantKeyring::new(vec![GrantKey::new("current", &[7; 32]).unwrap()]).unwrap();
let mut oversized = grant();
oversized.relay_pubkey = "a".repeat(MAX_GRANT_BYTES * 2);
assert!(ring.issue(&oversized).is_err());
let at_limit = "a".repeat(MAX_GRANT_BYTES);
let over_limit = "a".repeat(MAX_GRANT_BYTES + 1);
assert!(ring.open(&at_limit).is_err());
assert!(ring.open(&over_limit).is_err());
}
#[test]
fn tampering_unknown_ids_and_duplicate_configuration_fail() {
let ring = GrantKeyring::new(vec![GrantKey::new("current", &[7; 32]).unwrap()]).unwrap();
let sealed = ring.issue(&grant()).unwrap();
let mut bad = sealed.into_bytes();
let n = bad.len() - 1;
bad[n] = if bad[n] == b'A' { b'B' } else { b'A' };
assert!(ring.open(std::str::from_utf8(&bad).unwrap()).is_err());
let route_tampered = ring
.issue(&grant())
.unwrap()
.replacen("current.", "other.", 1);
assert!(ring.open(&route_tampered).is_err());
assert!(ring.open("unknown.AAAA").is_err());
assert!(matches!(
GrantKeyring::new(vec![
GrantKey::new("same", &[1; 32]).unwrap(),
GrantKey::new("same", &[2; 32]).unwrap(),
]),
Err(GrantError::DuplicateKeyId)
));
assert!(matches!(
GrantKeyring::new(Vec::new()),
Err(GrantError::EmptyKeyring)
));
}
}
+776
View File
@@ -0,0 +1,776 @@
//! Stateful installation, delegation, delivery, and health APIs.
use crate::{
apns::{DeliveryAttempt, DeliveryOutcome, PushTransport},
app_attest::AppAttestVerifier,
authority::{
AuthorityError, AuthorityStore, Challenge, Delegation, DeliveryDisposition, NewInstallation,
},
grant::GrantKeyring,
model::*,
token::TokenKeyring,
};
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use nostr::{
nips::nip98::{verify_auth_header, HttpMethod},
Event, JsonUtil, Timestamp,
};
use std::{
collections::HashSet,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
use tower::limit::ConcurrencyLimitLayer;
use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer};
#[derive(Clone)]
pub struct AppState {
pub grant_keyring: Arc<GrantKeyring>,
pub app_attest: Arc<AppAttestVerifier>,
pub authority: Arc<dyn AuthorityStore>,
pub token_keyring: Arc<TokenKeyring>,
pub transport: Arc<dyn PushTransport>,
pub delivery_url: url::Url,
pub max_grant_lifetime_seconds: i64,
pub max_installation_lifetime_seconds: i64,
pub endpoint_quota_window_seconds: i64,
pub endpoint_quota_max_deliveries: i64,
pub enabled_profiles: HashSet<AppProfile>,
pub now: fn() -> i64,
pub accepting: Arc<AtomicBool>,
}
fn error(status: StatusCode, code: &'static str) -> Response {
(status, Json(ErrorBody { error: code })).into_response()
}
fn valid_endpoint(v: &str) -> bool {
!v.is_empty()
&& v.len() <= MAX_ENDPOINT_HEX_BYTES * 2
&& v.len().is_multiple_of(2)
&& v.bytes()
.all(|b| b.is_ascii_hexdigit() && (!b.is_ascii_alphabetic() || b.is_ascii_lowercase()))
}
fn valid_relay_pubkey(v: &str) -> bool {
v.len() == 64
&& v.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn auth_event_id(header: &str) -> Option<String> {
let (prefix, encoded) = header.split_once(' ')?;
if prefix != "Nostr" {
return None;
}
Event::from_json(STANDARD.decode(encoded).ok()?)
.ok()
.map(|e| e.id.to_hex())
}
fn decode_challenge(value: &str) -> Option<[u8; 32]> {
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(value)
.ok()?;
bytes.try_into().ok()
}
fn authority_error(e: AuthorityError) -> Response {
match e {
AuthorityError::Rejected => error(StatusCode::NOT_FOUND, "not_authorized"),
AuthorityError::Unavailable => {
error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable")
}
}
}
fn endpoint_bytes(endpoint: &str) -> Option<Vec<u8>> {
valid_endpoint(endpoint)
.then(|| hex::decode(endpoint).ok())
.flatten()
}
fn endpoint_fingerprint(profile: AppProfile, token: &[u8]) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(b"buzz-apns-endpoint-v1\0");
h.update(profile.as_str().as_bytes());
h.update([0]);
h.update(token);
h.finalize().into()
}
fn transcript<T: serde::Serialize>(domain: &str, value: &T) -> Option<String> {
let body = serde_json::to_string(value).ok()?;
Some(format!("{domain}\n{body}"))
}
async fn challenge(State(s): State<AppState>, body: Bytes) -> Response {
let _r: InstallationChallengeRequest =
match crate::strict_json::from_slice::<InstallationChallengeRequest>(&body) {
Ok(r) if r.v == WIRE_VERSION => r,
_ => return error(StatusCode::BAD_REQUEST, "invalid_request"),
};
let now = (s.now)();
let expires_at = match now.checked_add(300) {
Some(v) => v,
None => return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"),
};
let mut value = [0u8; 32];
if getrandom::fill(&mut value).is_err() {
return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable");
}
let c = Challenge {
id: uuid::Uuid::new_v4(),
value,
expires_at,
};
if let Err(e) = s.authority.put_challenge(c.clone()).await {
return authority_error(e);
}
(
StatusCode::OK,
Json(InstallationChallengeResponse {
challenge_id: c.id,
challenge: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(c.value),
expires_at,
}),
)
.into_response()
}
#[derive(serde::Serialize)]
struct EnrollTranscript<'a> {
v: u8,
audience: &'static str,
challenge_id: uuid::Uuid,
challenge: &'a str,
key_id: &'a str,
app_profile: AppProfile,
endpoint: &'a str,
endpoint_epoch: i64,
expires_at: i64,
}
async fn enroll(State(s): State<AppState>, body: Bytes) -> Response {
let r: InstallationEnrollRequest = match crate::strict_json::from_slice(&body) {
Ok(r) => r,
Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"),
};
let now = (s.now)();
let token = match endpoint_bytes(&r.endpoint) {
Some(v) => v,
None => return error(StatusCode::BAD_REQUEST, "invalid_request"),
};
if r.v != WIRE_VERSION
|| r.endpoint_epoch != 1
|| r.expires_at <= now
|| r.expires_at > now.saturating_add(s.max_installation_lifetime_seconds)
|| !s.enabled_profiles.contains(&r.app_profile)
{
return error(StatusCode::BAD_REQUEST, "invalid_request");
}
let challenge = match decode_challenge(&r.challenge) {
Some(v) => v,
None => return error(StatusCode::BAD_REQUEST, "invalid_request"),
};
let t = EnrollTranscript {
v: r.v,
audience: "https://push.buzz.xyz/v1/installations",
challenge_id: r.challenge_id,
challenge: &r.challenge,
key_id: &r.key_id,
app_profile: r.app_profile,
endpoint: &r.endpoint,
endpoint_epoch: r.endpoint_epoch,
expires_at: r.expires_at,
};
let signed = match transcript("buzz.push.enroll.v1", &t) {
Some(v) => v,
None => return error(StatusCode::BAD_REQUEST, "invalid_request"),
};
let verified =
match s
.app_attest
.verify_attestation(&r.attestation, &r.key_id, signed.as_bytes())
{
Ok(v) => v,
Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"),
};
if let Err(e) = s
.authority
.consume_challenge(r.challenge_id, challenge, now)
.await
{
return authority_error(e);
}
let ciphertext = match s.token_keyring.seal(&token) {
Ok(v) => v,
Err(_) => return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"),
};
let id = uuid::Uuid::new_v4();
let n = NewInstallation {
id,
app_attest_key_id: verified.key_id,
app_attest_public_key: verified.public_key,
assertion_counter: 0,
profile: r.app_profile,
token_ciphertext: ciphertext,
token_fingerprint: endpoint_fingerprint(r.app_profile, &token),
endpoint_epoch: 1,
expires_at: r.expires_at,
};
if let Err(e) = s.authority.create_installation(n).await {
return authority_error(e);
}
(
StatusCode::CREATED,
Json(InstallationEnrollResponse {
installation_handle: id,
endpoint_epoch: 1,
expires_at: r.expires_at,
}),
)
.into_response()
}
async fn verify_installation_assertion<T: serde::Serialize>(
s: &AppState,
installation_id: uuid::Uuid,
challenge_id: uuid::Uuid,
challenge_text: &str,
assertion: &str,
domain: &str,
signed: &T,
) -> Result<(), Response> {
let now = (s.now)();
let challenge = decode_challenge(challenge_text)
.ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?;
let installation = s
.authority
.installation(installation_id, now)
.await
.map_err(authority_error)?;
let transcript = transcript(domain, signed)
.ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?;
let verified = s
.app_attest
.verify_assertion(
assertion,
transcript.as_bytes(),
&installation.app_attest_public_key,
installation.assertion_counter,
challenge_text,
challenge_text,
)
.map_err(|_| error(StatusCode::UNAUTHORIZED, "invalid_attestation"))?;
s.authority
.consume_challenge(challenge_id, challenge, now)
.await
.map_err(authority_error)?;
s.authority
.advance_assertion_counter(
installation_id,
installation.assertion_counter,
verified.counter,
)
.await
.map_err(authority_error)
}
#[derive(serde::Serialize)]
struct DelegateTranscript<'a> {
v: u8,
audience: &'static str,
challenge_id: uuid::Uuid,
challenge: &'a str,
installation_handle: uuid::Uuid,
endpoint_epoch: i64,
generation: i64,
relay_pubkey: &'a str,
not_before: i64,
expires_at: i64,
}
async fn delegate(State(s): State<AppState>, body: Bytes) -> Response {
let r: DelegationRequest = match crate::strict_json::from_slice(&body) {
Ok(r) => r,
Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"),
};
let now = (s.now)();
if r.v != WIRE_VERSION
|| !valid_relay_pubkey(&r.relay_pubkey)
|| r.endpoint_epoch < 1
|| r.generation < 1
|| r.not_before > now + 300
|| r.expires_at <= r.not_before
|| r.expires_at > now + s.max_grant_lifetime_seconds
{
return error(StatusCode::BAD_REQUEST, "invalid_request");
}
let t = DelegateTranscript {
v: r.v,
audience: "https://push.buzz.xyz/v1/delegations",
challenge_id: r.challenge_id,
challenge: &r.challenge,
installation_handle: r.installation_handle,
endpoint_epoch: r.endpoint_epoch,
generation: r.generation,
relay_pubkey: &r.relay_pubkey,
not_before: r.not_before,
expires_at: r.expires_at,
};
if let Err(e) = verify_installation_assertion(
&s,
r.installation_handle,
r.challenge_id,
&r.challenge,
&r.assertion,
"buzz.push.delegate.v1",
&t,
)
.await
{
return e;
}
let d = Delegation {
id: uuid::Uuid::new_v4(),
installation_id: r.installation_handle,
relay_pubkey: r.relay_pubkey.clone(),
endpoint_epoch: r.endpoint_epoch,
generation: r.generation,
not_before: r.not_before,
expires_at: r.expires_at,
revoked: false,
};
if let Err(e) = s.authority.upsert_delegation(d.clone()).await {
return authority_error(e);
}
let g = EndpointGrant {
v: WIRE_VERSION,
delegation_id: d.id,
relay_pubkey: d.relay_pubkey,
app_profile: match s.authority.installation(d.installation_id, now).await {
Ok(i) => i.profile,
Err(e) => return authority_error(e),
},
endpoint_epoch: d.endpoint_epoch,
generation: d.generation,
expires_at: d.expires_at,
};
match s.grant_keyring.issue(&g) {
Ok(endpoint_grant) => (
StatusCode::CREATED,
Json(DelegationResponse { endpoint_grant }),
)
.into_response(),
Err(_) => error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"),
}
}
#[derive(serde::Serialize)]
struct RotateTranscript<'a> {
v: u8,
audience: &'static str,
challenge_id: uuid::Uuid,
challenge: &'a str,
installation_handle: uuid::Uuid,
endpoint_epoch: i64,
new_endpoint_epoch: i64,
endpoint: &'a str,
}
async fn rotate_endpoint(State(s): State<AppState>, body: Bytes) -> Response {
let r: RotateEndpointRequest = match crate::strict_json::from_slice(&body) {
Ok(r) => r,
Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"),
};
let token = match endpoint_bytes(&r.endpoint) {
Some(v) => v,
None => return error(StatusCode::BAD_REQUEST, "invalid_request"),
};
if r.v != WIRE_VERSION
|| r.endpoint_epoch < 1
|| r.new_endpoint_epoch != r.endpoint_epoch.saturating_add(1)
{
return error(StatusCode::BAD_REQUEST, "invalid_request");
}
let installation = match s
.authority
.installation(r.installation_handle, (s.now)())
.await
{
Ok(i) => i,
Err(e) => return authority_error(e),
};
let t = RotateTranscript {
v: r.v,
audience: "https://push.buzz.xyz/v1/installations/endpoint",
challenge_id: r.challenge_id,
challenge: &r.challenge,
installation_handle: r.installation_handle,
endpoint_epoch: r.endpoint_epoch,
new_endpoint_epoch: r.new_endpoint_epoch,
endpoint: &r.endpoint,
};
if let Err(e) = verify_installation_assertion(
&s,
r.installation_handle,
r.challenge_id,
&r.challenge,
&r.assertion,
"buzz.push.rotate-endpoint.v1",
&t,
)
.await
{
return e;
}
let ciphertext = match s.token_keyring.seal(&token) {
Ok(v) => v,
Err(_) => return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"),
};
match s
.authority
.rotate_endpoint(
r.installation_handle,
r.endpoint_epoch,
r.new_endpoint_epoch,
ciphertext,
endpoint_fingerprint(installation.profile, &token),
)
.await
{
Ok(()) => (StatusCode::OK, Json(MutationResponse { status: "rotated" })).into_response(),
Err(e) => authority_error(e),
}
}
#[derive(serde::Serialize)]
struct RevokeDelegationTranscript<'a> {
v: u8,
audience: &'static str,
challenge_id: uuid::Uuid,
challenge: &'a str,
installation_handle: uuid::Uuid,
relay_pubkey: &'a str,
generation: i64,
}
async fn revoke_delegation(State(s): State<AppState>, body: Bytes) -> Response {
let r: RevokeDelegationRequest = match crate::strict_json::from_slice(&body) {
Ok(r) => r,
Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"),
};
if r.v != WIRE_VERSION || !valid_relay_pubkey(&r.relay_pubkey) || r.generation < 1 {
return error(StatusCode::BAD_REQUEST, "invalid_request");
}
let t = RevokeDelegationTranscript {
v: r.v,
audience: "https://push.buzz.xyz/v1/delegations/revoke",
challenge_id: r.challenge_id,
challenge: &r.challenge,
installation_handle: r.installation_handle,
relay_pubkey: &r.relay_pubkey,
generation: r.generation,
};
if let Err(e) = verify_installation_assertion(
&s,
r.installation_handle,
r.challenge_id,
&r.challenge,
&r.assertion,
"buzz.push.revoke-delegation.v1",
&t,
)
.await
{
return e;
}
match s
.authority
.revoke_delegation(r.installation_handle, &r.relay_pubkey, r.generation)
.await
{
Ok(()) => (StatusCode::OK, Json(MutationResponse { status: "revoked" })).into_response(),
Err(e) => authority_error(e),
}
}
#[derive(serde::Serialize)]
struct RevokeInstallationTranscript<'a> {
v: u8,
audience: &'static str,
challenge_id: uuid::Uuid,
challenge: &'a str,
installation_handle: uuid::Uuid,
endpoint_epoch: i64,
new_endpoint_epoch: i64,
}
async fn revoke_installation(State(s): State<AppState>, body: Bytes) -> Response {
let r: RevokeInstallationRequest = match crate::strict_json::from_slice(&body) {
Ok(r) => r,
Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_request"),
};
if r.v != WIRE_VERSION
|| r.endpoint_epoch < 1
|| r.new_endpoint_epoch != r.endpoint_epoch.saturating_add(1)
{
return error(StatusCode::BAD_REQUEST, "invalid_request");
}
let t = RevokeInstallationTranscript {
v: r.v,
audience: "https://push.buzz.xyz/v1/installations/revoke",
challenge_id: r.challenge_id,
challenge: &r.challenge,
installation_handle: r.installation_handle,
endpoint_epoch: r.endpoint_epoch,
new_endpoint_epoch: r.new_endpoint_epoch,
};
if let Err(e) = verify_installation_assertion(
&s,
r.installation_handle,
r.challenge_id,
&r.challenge,
&r.assertion,
"buzz.push.revoke-installation.v1",
&t,
)
.await
{
return e;
}
match s
.authority
.revoke_installation(
r.installation_handle,
r.endpoint_epoch,
r.new_endpoint_epoch,
)
.await
{
Ok(()) => (StatusCode::OK, Json(MutationResponse { status: "revoked" })).into_response(),
Err(e) => authority_error(e),
}
}
async fn deliver(State(s): State<AppState>, 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"),
};
if r.v != WIRE_VERSION {
return error(StatusCode::BAD_REQUEST, "invalid_request");
}
let auth = match headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
{
Some(x) => x,
None => return error(StatusCode::UNAUTHORIZED, "invalid_auth"),
};
let event_id = match auth_event_id(auth) {
Some(x) => x,
None => return error(StatusCode::UNAUTHORIZED, "invalid_auth"),
};
let relay = match verify_auth_header(
auth,
&s.delivery_url,
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"),
};
let now = (s.now)();
if grant.v != WIRE_VERSION
|| !valid_relay_pubkey(&grant.relay_pubkey)
|| grant.relay_pubkey != relay
|| grant.endpoint_epoch < 1
|| grant.generation < 1
|| grant.expires_at < now
|| r.expires_at < now
|| r.expires_at > grant.expires_at
{
return error(StatusCode::NOT_FOUND, "invalid_grant");
}
let permit = match s
.authority
.authorize_delivery(
grant.delegation_id,
&relay,
grant.endpoint_epoch,
grant.generation,
&event_id,
r.request_id,
r.expires_at,
s.endpoint_quota_window_seconds,
s.endpoint_quota_max_deliveries,
now,
)
.await
{
Ok(permit) => {
crate::metrics::record_admission(crate::metrics::Admission::Admitted);
permit
}
Err(AuthorityError::Rejected) => {
crate::metrics::record_admission(crate::metrics::Admission::Rejected);
crate::metrics::record_delivery_error("invalid_grant");
return error(StatusCode::NOT_FOUND, "invalid_grant");
}
Err(AuthorityError::Unavailable) => {
crate::metrics::record_admission(crate::metrics::Admission::Unavailable);
crate::metrics::record_delivery_error("temporarily_unavailable");
return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable");
}
};
if permit.authority.profile != grant.app_profile {
crate::metrics::record_delivery_error("profile_mismatch");
let _ = s
.authority
.finish_delivery(permit, DeliveryDisposition::Terminal)
.await;
return error(StatusCode::NOT_FOUND, "invalid_grant");
}
let profile = permit.authority.profile;
let endpoint = match s.token_keyring.open(&permit.authority.token_ciphertext) {
Ok(token) => hex::encode(token),
Err(_) => {
crate::metrics::record_delivery_error("token_custody");
let _ = s
.authority
.finish_delivery(permit, DeliveryDisposition::Retryable)
.await;
return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable");
}
};
let attempt = DeliveryAttempt {
request_id: r.request_id,
expires_at: r.expires_at,
};
let transport = Arc::clone(&s.transport);
let authority_store = Arc::clone(&s.authority);
// Admission already committed, so cancellation cannot undo either replay
// fence. The detached task completes disposition bookkeeping.
let delivery = tokio::spawn(async move {
let started = std::time::Instant::now();
let mut outcome = transport.send(attempt, profile, &endpoint).await;
if outcome == DeliveryOutcome::RefreshCredential {
crate::metrics::record_credential_refresh();
transport.refresh_credential();
outcome = transport.send(attempt, profile, &endpoint).await;
}
crate::metrics::record_apns_delivery(outcome, started.elapsed().as_secs_f64());
let disposition = match outcome {
DeliveryOutcome::Retry { .. }
| DeliveryOutcome::ConfigurationFault
| DeliveryOutcome::RefreshCredential => DeliveryDisposition::Retryable,
DeliveryOutcome::Accepted
| DeliveryOutcome::InvalidEndpoint { .. }
| DeliveryOutcome::PermanentRequestFault => DeliveryDisposition::Terminal,
};
authority_store
.finish_delivery(permit, disposition)
.await
.map(|()| outcome)
});
let outcome = match delivery.await {
Ok(Ok(outcome)) => outcome,
_ => {
crate::metrics::record_delivery_error("finish_failed");
return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable");
}
};
match outcome {
DeliveryOutcome::Accepted => {
(StatusCode::OK, Json(DeliveryResponse::Accepted)).into_response()
}
DeliveryOutcome::InvalidEndpoint { unregistered_at } => (
StatusCode::GONE,
Json(DeliveryResponse::InvalidEndpoint {
generation: grant.generation,
invalid_at: unregistered_at,
}),
)
.into_response(),
DeliveryOutcome::Retry {
retry_after_seconds,
} => (
StatusCode::SERVICE_UNAVAILABLE,
Json(DeliveryResponse::Retry {
retry_after_seconds,
}),
)
.into_response(),
DeliveryOutcome::ConfigurationFault | DeliveryOutcome::RefreshCredential => {
error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault")
}
DeliveryOutcome::PermanentRequestFault => error(StatusCode::BAD_REQUEST, "invalid_request"),
}
}
async fn live() -> Json<serde_json::Value> {
Json(serde_json::json!({"status":"alive"}))
}
async fn ready(State(s): State<AppState>) -> Response {
if !s.accepting.load(Ordering::Relaxed) {
crate::metrics::record_readiness_failure(crate::metrics::ReadinessFailure::NotAccepting);
return error(StatusCode::SERVICE_UNAVAILABLE, "not_ready");
}
if s.authority.ready().await.is_err() {
crate::metrics::record_readiness_failure(crate::metrics::ReadinessFailure::Authority);
return error(StatusCode::SERVICE_UNAVAILABLE, "not_ready");
}
Json(serde_json::json!({"status":"ready"})).into_response()
}
pub fn router(state: AppState) -> (Router, Router) {
router_with_metrics(state, None)
}
/// Build the public and private routers. When `metrics_handle` is provided, the
/// private health router additionally serves `GET /metrics` in Prometheus text
/// format. Metrics live only on the private router, never on the public port.
pub fn router_with_metrics(
state: AppState,
metrics_handle: Option<metrics_exporter_prometheus::PrometheusHandle>,
) -> (Router, Router) {
let public = Router::new()
.route("/v1/installations/challenges", post(challenge))
.route("/v1/installations", post(enroll))
.route("/v1/delegations", post(delegate))
.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))
.with_state(state.clone())
.layer(RequestBodyLimitLayer::new(MAX_REQUEST_BYTES))
.layer(ConcurrencyLimitLayer::new(256))
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(20),
));
let mut health = Router::new()
.route("/_liveness", get(live))
.route("/_readiness", get(ready))
.with_state(state);
if let Some(handle) = metrics_handle {
health = health.route(
"/metrics",
get(move || {
let handle = handle.clone();
async move {
handle.run_upkeep();
(
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4",
)],
handle.render(),
)
}
}),
);
}
(public, health)
}
+13
View File
@@ -0,0 +1,13 @@
//! Stateful, capability-gated APNs last hop for NIP-PL.
pub mod apns;
pub mod app_attest;
pub mod authority;
pub mod config;
pub mod grant;
pub mod http;
pub mod metrics;
pub mod model;
pub mod postgres;
pub(crate) mod strict_json;
pub mod token;
pub use http::{router, router_with_metrics, AppState};
+143
View File
@@ -0,0 +1,143 @@
use buzz_push_gateway::{
apns::ApnsTransport,
app_attest::AppAttestVerifier,
authority::AuthorityStore,
config::Config,
grant::{GrantKey, GrantKeyring},
postgres::PostgresAuthorityStore,
router_with_metrics,
token::{TokenKey, TokenKeyring},
AppState,
};
use std::{
fs,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.json()
.with_env_filter(EnvFilter::from_default_env())
.init();
if std::env::args().nth(1).as_deref() == Some("--migrate-only") {
let database_url = std::env::var("DATABASE_URL")?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await?;
let runtime_role = std::env::var("BUZZ_PUSH_RUNTIME_DATABASE_ROLE")?;
PostgresAuthorityStore::apply_migrations_and_grants(&pool, &runtime_role).await?;
return Ok(());
}
let c = Config::from_env()?;
let metrics_handle = buzz_push_gateway::metrics::install()?;
let transport = Arc::new(ApnsTransport::token(
&fs::read(&c.apns_key_path)?,
&c.apns_key_id,
&c.apns_team_id,
c.apns_topic,
)?);
let grant_keyring = GrantKeyring::new(
c.grant_keys
.iter()
.map(|key| GrantKey::new(&key.id, &key.key))
.collect::<Result<_, _>>()?,
)?;
let token_keyring = TokenKeyring::new(
c.token_keys
.iter()
.map(|key| TokenKey::new(&key.id, &key.key))
.collect::<Result<_, _>>()?,
)?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(20)
.connect(&c.database_url)
.await?;
let authority = Arc::new(PostgresAuthorityStore::new(pool));
authority
.reap_expired(chrono::Utc::now().timestamp())
.await?;
let reaper_authority = Arc::clone(&authority);
let reaper = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
interval.tick().await;
loop {
interval.tick().await;
if reaper_authority
.reap_expired(chrono::Utc::now().timestamp())
.await
.is_err()
{
buzz_push_gateway::metrics::record_reaper_failure();
tracing::warn!("push gateway retention reaper failed");
}
}
});
let app_attest = Arc::new(AppAttestVerifier::new(
c.app_attest_app_id,
fs::read(&c.app_attest_root_cert_path)?,
)?);
let accepting = Arc::new(AtomicBool::new(true));
let (public, health) = router_with_metrics(
AppState {
grant_keyring: Arc::new(grant_keyring),
app_attest,
authority,
token_keyring: Arc::new(token_keyring),
transport,
delivery_url: c.public_delivery_url,
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,
endpoint_quota_max_deliveries: c.endpoint_quota_max_deliveries,
enabled_profiles: c.enabled_profiles,
now: || chrono::Utc::now().timestamp(),
accepting: accepting.clone(),
},
Some(metrics_handle),
);
let pl = tokio::net::TcpListener::bind(c.bind_addr).await?;
let hl = tokio::net::TcpListener::bind(c.health_addr).await?;
let (ptx, prx) = tokio::sync::watch::channel(false);
let (htx, hrx) = tokio::sync::watch::channel(false);
let p = tokio::spawn(async move {
axum::serve(pl, public)
.with_graceful_shutdown(async move {
let mut rx = prx;
let _ = rx.changed().await;
})
.await
});
let h = tokio::spawn(async move {
axum::serve(hl, health)
.with_graceful_shutdown(async move {
let mut rx = hrx;
let _ = rx.changed().await;
})
.await
});
shutdown_signal().await?;
accepting.store(false, Ordering::SeqCst);
let _ = ptx.send(true);
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), p).await;
let _ = htx.send(true);
let _ = h.await;
reaper.abort();
Ok(())
}
async fn shutdown_signal() -> std::io::Result<()> {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut term = signal(SignalKind::terminate())?;
tokio::select! {r=tokio::signal::ctrl_c()=>r,_=term.recv()=>Ok(())}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c().await
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Sanitized, bounded-cardinality Prometheus metrics for the push gateway.
//!
//! ```text
//! ┌──────────────────────────────────────────────────────────┐
//! │ metrics-rs facade (metrics::counter!, histogram!) │
//! │ ↓ │
//! │ PrometheusBuilder::install_recorder() → PrometheusHandle │
//! │ ↓ │
//! │ GET /metrics on the PRIVATE health router (port 8081) │
//! └──────────────────────────────────────────────────────────┘
//! ```
//!
//! Every label value emitted here is a compile-time `&'static str` drawn from a
//! closed set (the [`DeliveryOutcome`] variants, the gateway's fixed error
//! codes, and the handler stages). No endpoint, device token, relay pubkey,
//! request id, or any other request-scoped identifier is ever used as a label,
//! so metric cardinality is structurally bounded regardless of traffic.
use crate::apns::DeliveryOutcome;
use metrics_exporter_prometheus::{BuildError, Matcher, PrometheusBuilder, PrometheusHandle};
/// Seconds-scale buckets for the APNs send round-trip histogram.
const APNS_LATENCY_BUCKETS_S: [f64; 11] = [
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 15.0,
];
/// Install the global metrics recorder and return the render handle.
///
/// Unlike the relay's exporter, this installs **no** HTTP listener: rendering is
/// served from the private health router so metrics never share the public port.
/// Must be called at most once per process, from within a Tokio runtime.
pub fn install() -> Result<PrometheusHandle, BuildError> {
let handle = PrometheusBuilder::new()
.set_buckets_for_metric(
Matcher::Full("push_gateway_apns_delivery_seconds".to_owned()),
&APNS_LATENCY_BUCKETS_S,
)?
.install_recorder()?;
Ok(handle)
}
/// Stable metric label for each sanitized delivery outcome. The mapping is total
/// over the closed [`DeliveryOutcome`] enum, so the `outcome` label can only take
/// these six values.
fn outcome_label(outcome: DeliveryOutcome) -> &'static str {
match outcome {
DeliveryOutcome::Accepted => "accepted",
DeliveryOutcome::InvalidEndpoint { .. } => "invalid_endpoint",
DeliveryOutcome::Retry { .. } => "retry",
DeliveryOutcome::RefreshCredential => "refresh_credential",
DeliveryOutcome::ConfigurationFault => "configuration_fault",
DeliveryOutcome::PermanentRequestFault => "permanent_request_fault",
}
}
/// 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))
.increment(1);
metrics::histogram!("push_gateway_apns_delivery_seconds").record(seconds);
}
/// Record that a cached provider credential was refreshed after APNs reported expiry.
pub fn record_credential_refresh() {
metrics::counter!("push_gateway_apns_credential_refreshes_total").increment(1);
}
/// Delivery-admission result at the `authorize_delivery` seam.
#[derive(Debug, Clone, Copy)]
pub enum Admission {
/// A delivery permit was issued.
Admitted,
/// The replay/quota/authority fence rejected the request.
Rejected,
/// The authority store was transiently unavailable.
Unavailable,
}
/// Record the outcome of a delivery-admission attempt.
pub fn record_admission(result: Admission) {
let label = match result {
Admission::Admitted => "admitted",
Admission::Rejected => "rejected",
Admission::Unavailable => "unavailable",
};
metrics::counter!("push_gateway_admissions_total", "result" => label).increment(1);
}
/// Record a delivery-path error, tagged by the static failure class. This
/// counter covers only the `/v1/deliveries/apns` handler's post-admission exit
/// classes (admission rejection/unavailability, profile mismatch, token-custody
/// open failure, and detached finish/join failure); pre-admission request/auth/
/// attestation validation on the enrollment and delegation handlers is not
/// counted here. `class` is always a compile-time constant.
pub fn record_delivery_error(class: &'static str) {
metrics::counter!("push_gateway_delivery_errors_total", "class" => class).increment(1);
}
/// Record a retention-reaper sweep failure.
pub fn record_reaper_failure() {
metrics::counter!("push_gateway_reaper_failures_total").increment(1);
}
/// Why a readiness probe reported not-ready.
#[derive(Debug, Clone, Copy)]
pub enum ReadinessFailure {
/// The process is draining and no longer accepting traffic.
NotAccepting,
/// The authority store readiness check failed.
Authority,
}
/// Record a readiness-probe failure by cause.
pub fn record_readiness_failure(cause: ReadinessFailure) {
let label = match cause {
ReadinessFailure::NotAccepting => "not_accepting",
ReadinessFailure::Authority => "authority",
};
metrics::counter!("push_gateway_readiness_failures_total", "cause" => label).increment(1);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outcome_label_covers_every_variant_with_static_strings() {
// Exhaustive over the closed enum; each arm is a compile-time constant,
// so the `outcome` label is structurally bounded to these six values.
for (outcome, expected) in [
(DeliveryOutcome::Accepted, "accepted"),
(
DeliveryOutcome::InvalidEndpoint {
unregistered_at: Some(7),
},
"invalid_endpoint",
),
(
DeliveryOutcome::Retry {
retry_after_seconds: Some(30),
},
"retry",
),
(DeliveryOutcome::RefreshCredential, "refresh_credential"),
(DeliveryOutcome::ConfigurationFault, "configuration_fault"),
(
DeliveryOutcome::PermanentRequestFault,
"permanent_request_fault",
),
] {
assert_eq!(outcome_label(outcome), expected);
}
}
// The global metrics recorder can be installed only once per process, so a
// single test owns the install and exercises every helper end-to-end,
// asserting the rendered exposition is sanitized and bounded-cardinality.
#[test]
fn recorder_renders_sanitized_bounded_series() {
let handle = install().expect("recorder installs exactly once per test process");
record_apns_delivery(DeliveryOutcome::Accepted, 0.012);
record_apns_delivery(
DeliveryOutcome::InvalidEndpoint {
unregistered_at: None,
},
0.030,
);
record_credential_refresh();
record_admission(Admission::Admitted);
record_admission(Admission::Rejected);
record_admission(Admission::Unavailable);
record_delivery_error("invalid_grant");
record_delivery_error("finish_failed");
record_reaper_failure();
record_readiness_failure(ReadinessFailure::NotAccepting);
record_readiness_failure(ReadinessFailure::Authority);
let rendered = handle.render();
// All expected series are present.
for needle in [
"push_gateway_apns_deliveries_total",
"push_gateway_apns_delivery_seconds",
"push_gateway_apns_credential_refreshes_total",
"push_gateway_admissions_total",
"push_gateway_delivery_errors_total",
"push_gateway_reaper_failures_total",
"push_gateway_readiness_failures_total",
] {
assert!(rendered.contains(needle), "missing series {needle}");
}
// Labels are the closed static sets only.
for needle in [
"outcome=\"accepted\"",
"outcome=\"invalid_endpoint\"",
"result=\"admitted\"",
"result=\"rejected\"",
"result=\"unavailable\"",
"class=\"invalid_grant\"",
"cause=\"not_accepting\"",
"cause=\"authority\"",
] {
assert!(rendered.contains(needle), "missing label {needle}");
}
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Closed wire types for the stateful gateway.
use serde::{Deserialize, Serialize};
pub const MAX_REQUEST_BYTES: usize = 8 * 1024;
pub const MAX_GRANT_BYTES: usize = 4096;
pub const MAX_ENDPOINT_HEX_BYTES: usize = 512;
pub const APNS_RECONNECT_PAYLOAD: &[u8] =
br#"{"aps":{"alert":{"body":"Reconnect to your relay now"},"mutable-content":1}}"#;
pub const WIRE_VERSION: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AppProfile {
BuzzIosProduction,
BuzzIosSandbox,
}
impl AppProfile {
pub const fn as_str(self) -> &'static str {
match self {
Self::BuzzIosProduction => "buzz-ios-production",
Self::BuzzIosSandbox => "buzz-ios-sandbox",
}
}
}
/// Relay request. It deliberately has no application-payload field:
/// the gateway emits one compiled-in APNs reconnect payload for every delivery.
/// `endpoint_grant` is opaque authenticated ciphertext minted by the gateway
/// sealing key and persisted with the relay-owned lease.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeliveryRequest {
pub v: u8,
pub endpoint_grant: String,
pub request_id: uuid::Uuid,
pub expires_at: i64,
}
/// Opaque delivery capability plaintext. It contains no APNs token: the random
/// delegation id resolves through durable authority state, while the remaining
/// fields are authenticated fences that make stale or cross-relay use fail.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EndpointGrant {
pub v: u8,
pub delegation_id: uuid::Uuid,
pub relay_pubkey: String,
pub app_profile: AppProfile,
pub endpoint_epoch: i64,
pub generation: i64,
pub expires_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstallationChallengeRequest {
pub v: u8,
}
#[derive(Debug, Clone, Serialize)]
pub struct InstallationChallengeResponse {
pub challenge_id: uuid::Uuid,
pub challenge: String,
pub expires_at: i64,
}
/// Direct app enrollment. `attestation` is Apple's CBOR object and `key_id` is
/// the App Attest key identifier, both base64 encoded. The attested key is the
/// installation authority; no second application signing key is introduced.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstallationEnrollRequest {
pub v: u8,
pub challenge_id: uuid::Uuid,
pub challenge: String,
pub key_id: String,
pub attestation: String,
pub app_profile: AppProfile,
pub endpoint: String,
pub endpoint_epoch: i64,
pub expires_at: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct InstallationEnrollResponse {
pub installation_handle: uuid::Uuid,
pub endpoint_epoch: i64,
pub expires_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DelegationRequest {
pub v: u8,
pub challenge_id: uuid::Uuid,
pub challenge: String,
pub installation_handle: uuid::Uuid,
pub endpoint_epoch: i64,
pub generation: i64,
pub relay_pubkey: String,
pub not_before: i64,
pub expires_at: i64,
pub assertion: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DelegationResponse {
pub endpoint_grant: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RotateEndpointRequest {
pub v: u8,
pub challenge_id: uuid::Uuid,
pub challenge: String,
pub installation_handle: uuid::Uuid,
pub endpoint_epoch: i64,
pub new_endpoint_epoch: i64,
pub endpoint: String,
pub assertion: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RevokeDelegationRequest {
pub v: u8,
pub challenge_id: uuid::Uuid,
pub challenge: String,
pub installation_handle: uuid::Uuid,
pub relay_pubkey: String,
pub generation: i64,
pub assertion: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RevokeInstallationRequest {
pub v: u8,
pub challenge_id: uuid::Uuid,
pub challenge: String,
pub installation_handle: uuid::Uuid,
pub endpoint_epoch: i64,
pub new_endpoint_epoch: i64,
pub assertion: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct MutationResponse {
pub status: &'static str,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "status", deny_unknown_fields)]
pub enum DeliveryResponse {
Accepted,
InvalidEndpoint {
generation: i64,
invalid_at: Option<i64>,
},
Retry {
retry_after_seconds: Option<i64>,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct ErrorBody {
pub error: &'static str,
}
+952
View File
@@ -0,0 +1,952 @@
//! PostgreSQL authority store. Mutations use row locks and compare-and-swap
//! predicates so counters, epochs, and generation tombstones only move forward.
use crate::authority::*;
use crate::model::AppProfile;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{AssertSqlSafe, PgPool, Row};
use uuid::Uuid;
#[derive(Clone)]
pub struct PostgresAuthorityStore {
pool: PgPool,
}
impl PostgresAuthorityStore {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn apply_migrations_and_grants(
pool: &PgPool,
runtime_role: &str,
) -> Result<(), Box<dyn std::error::Error>> {
sqlx::migrate!("./migrations").run(pool).await?;
if runtime_role.is_empty()
|| runtime_role.len() > 63
|| !runtime_role
.bytes()
.enumerate()
.all(|(i, b)| b == b'_' || b.is_ascii_alphabetic() || (i > 0 && b.is_ascii_digit()))
{
return Err("runtime database role must be a PostgreSQL identifier".into());
}
let database: String = sqlx::query_scalar("SELECT current_database()")
.fetch_one(pool)
.await?;
let quote_ident = |value: &str| format!("\"{}\"", value.replace('"', "\"\""));
let role = quote_ident(runtime_role);
let database = quote_ident(&database);
let grants = format!(
"REVOKE CREATE ON DATABASE {database} FROM {role};
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
REVOKE CREATE ON SCHEMA public FROM {role};
GRANT CONNECT ON DATABASE {database} TO {role};
GRANT USAGE ON SCHEMA public TO {role};
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
push_gateway_challenges,
push_gateway_installations,
push_gateway_delegations,
push_gateway_endpoint_quotas,
push_gateway_delivery_auth_replays,
push_gateway_delivery_request_replays
TO {role};"
);
sqlx::raw_sql(AssertSqlSafe(grants)).execute(pool).await?;
Ok(())
}
}
fn at(ts: i64) -> Result<DateTime<Utc>, AuthorityError> {
DateTime::from_timestamp(ts, 0).ok_or(AuthorityError::Rejected)
}
fn ts(v: DateTime<Utc>) -> i64 {
v.timestamp()
}
fn profile(v: &str) -> Result<AppProfile, AuthorityError> {
match v {
"buzz-ios-production" => Ok(AppProfile::BuzzIosProduction),
"buzz-ios-sandbox" => Ok(AppProfile::BuzzIosSandbox),
_ => Err(AuthorityError::Unavailable),
}
}
fn db(_: sqlx::Error) -> AuthorityError {
AuthorityError::Unavailable
}
fn bytes32(v: Vec<u8>) -> Result<[u8; 32], AuthorityError> {
v.try_into().map_err(|_| AuthorityError::Unavailable)
}
#[async_trait]
impl AuthorityStore for PostgresAuthorityStore {
async fn ready(&self) -> Result<(), AuthorityError> {
const TABLES: [&str; 6] = [
"push_gateway_challenges",
"push_gateway_installations",
"push_gateway_delegations",
"push_gateway_endpoint_quotas",
"push_gateway_delivery_auth_replays",
"push_gateway_delivery_request_replays",
];
let mut tx = self.pool.begin().await.map_err(db)?;
for table in TABLES {
let ready: bool = sqlx::query_scalar(
"SELECT to_regclass($1) IS NOT NULL
AND COALESCE(has_table_privilege(current_user, to_regclass($1), 'SELECT'), false)
AND COALESCE(has_table_privilege(current_user, to_regclass($1), 'INSERT'), false)
AND COALESCE(has_table_privilege(current_user, to_regclass($1), 'UPDATE'), false)
AND COALESCE(has_table_privilege(current_user, to_regclass($1), 'DELETE'), false)",
)
.bind(format!("public.{table}"))
.fetch_one(&mut *tx)
.await
.map_err(db)?;
if !ready {
return Err(AuthorityError::Unavailable);
}
}
let least_privilege: bool = sqlx::query_scalar(
"SELECT has_database_privilege(current_user, current_database(), 'CONNECT')
AND NOT has_database_privilege(current_user, current_database(), 'CREATE')
AND NOT has_schema_privilege(current_user, 'public', 'CREATE')",
)
.fetch_one(&mut *tx)
.await
.map_err(db)?;
if !least_privilege {
return Err(AuthorityError::Unavailable);
}
tx.rollback().await.map_err(db)
}
async fn put_challenge(&self, c: Challenge) -> Result<(), AuthorityError> {
use sha2::{Digest, Sha256};
sqlx::query(
"INSERT INTO push_gateway_challenges(id,challenge_hash,expires_at) VALUES($1,$2,$3)",
)
.bind(c.id)
.bind(Sha256::digest(c.value).to_vec())
.bind(at(c.expires_at)?)
.execute(&self.pool)
.await
.map_err(db)?;
Ok(())
}
async fn consume_challenge(
&self,
id: Uuid,
value: [u8; 32],
now: i64,
) -> Result<(), AuthorityError> {
use sha2::{Digest, Sha256};
let result = sqlx::query("DELETE FROM push_gateway_challenges WHERE id=$1 AND challenge_hash=$2 AND expires_at >= $3")
.bind(id).bind(Sha256::digest(value).to_vec()).bind(at(now)?).execute(&self.pool).await.map_err(db)?;
if result.rows_affected() != 1 {
return Err(AuthorityError::Rejected);
}
Ok(())
}
async fn create_installation(&self, n: NewInstallation) -> Result<(), AuthorityError> {
let result = sqlx::query("INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING")
.bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.profile.as_str()).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&self.pool).await.map_err(db)?;
if result.rows_affected() != 1 {
return Err(AuthorityError::Rejected);
}
Ok(())
}
async fn installation(&self, id: Uuid, now: i64) -> Result<Installation, AuthorityError> {
let r = sqlx::query("SELECT * FROM push_gateway_installations WHERE id=$1 AND revoked_at IS NULL AND expires_at >= $2")
.bind(id).bind(at(now)?).fetch_optional(&self.pool).await.map_err(db)?.ok_or(AuthorityError::Rejected)?;
Ok(Installation {
id,
app_attest_key_id: r.try_get("app_attest_key_id").map_err(db)?,
app_attest_public_key: r.try_get("app_attest_public_key").map_err(db)?,
assertion_counter: u32::try_from(r.try_get::<i64, _>("assertion_counter").map_err(db)?)
.map_err(|_| AuthorityError::Unavailable)?,
profile: profile(r.try_get("app_profile").map_err(db)?)?,
token_ciphertext: r.try_get("token_ciphertext").map_err(db)?,
token_fingerprint: bytes32(r.try_get("token_fingerprint").map_err(db)?)?,
endpoint_epoch: r.try_get("endpoint_epoch").map_err(db)?,
expires_at: ts(r.try_get("expires_at").map_err(db)?),
revoked: false,
})
}
async fn advance_assertion_counter(
&self,
id: Uuid,
previous: u32,
next: u32,
) -> Result<(), AuthorityError> {
if next <= previous {
return Err(AuthorityError::Rejected);
}
let result=sqlx::query("UPDATE push_gateway_installations SET assertion_counter=$3,updated_at=now() WHERE id=$1 AND assertion_counter=$2 AND revoked_at IS NULL")
.bind(id).bind(i64::from(previous)).bind(i64::from(next)).execute(&self.pool).await.map_err(db)?;
if result.rows_affected() != 1 {
return Err(AuthorityError::Rejected);
}
Ok(())
}
async fn upsert_delegation(&self, d: Delegation) -> Result<(), AuthorityError> {
let mut tx = self.pool.begin().await.map_err(db)?;
let i=sqlx::query("SELECT endpoint_epoch,expires_at,revoked_at FROM push_gateway_installations WHERE id=$1 FOR UPDATE").bind(d.installation_id).fetch_optional(&mut *tx).await.map_err(db)?.ok_or(AuthorityError::Rejected)?;
if i.try_get::<Option<DateTime<Utc>>, _>("revoked_at")
.map_err(db)?
.is_some()
|| i.try_get::<i64, _>("endpoint_epoch").map_err(db)? != d.endpoint_epoch
|| at(d.expires_at)? > i.try_get::<DateTime<Utc>, _>("expires_at").map_err(db)?
{
return Err(AuthorityError::Rejected);
}
let relay = hex::decode(&d.relay_pubkey).map_err(|_| AuthorityError::Rejected)?;
let result=sqlx::query("INSERT INTO push_gateway_delegations(id,installation_id,relay_pubkey,endpoint_epoch,generation,not_before,expires_at,revoked_at) VALUES($1,$2,$3,$4,$5,$6,$7,NULL) ON CONFLICT(installation_id,relay_pubkey) DO UPDATE SET id=EXCLUDED.id,endpoint_epoch=EXCLUDED.endpoint_epoch,generation=EXCLUDED.generation,not_before=EXCLUDED.not_before,expires_at=EXCLUDED.expires_at,revoked_at=NULL,updated_at=now() WHERE EXCLUDED.generation > push_gateway_delegations.generation")
.bind(d.id).bind(d.installation_id).bind(relay).bind(d.endpoint_epoch).bind(d.generation).bind(at(d.not_before)?).bind(at(d.expires_at)?).execute(&mut *tx).await.map_err(db)?;
if result.rows_affected() != 1 {
return Err(AuthorityError::Rejected);
}
tx.commit().await.map_err(db)?;
Ok(())
}
async fn rotate_endpoint(
&self,
id: Uuid,
expected: i64,
new: i64,
ciphertext: Vec<u8>,
fingerprint: [u8; 32],
) -> Result<(), AuthorityError> {
if new != expected.checked_add(1).ok_or(AuthorityError::Rejected)? {
return Err(AuthorityError::Rejected);
}
let result=sqlx::query("UPDATE push_gateway_installations SET endpoint_epoch=$3,token_ciphertext=$4,token_fingerprint=$5,updated_at=now() WHERE id=$1 AND endpoint_epoch=$2 AND revoked_at IS NULL").bind(id).bind(expected).bind(new).bind(ciphertext).bind(fingerprint.to_vec()).execute(&self.pool).await.map_err(db)?;
if result.rows_affected() != 1 {
return Err(AuthorityError::Rejected);
}
Ok(())
}
async fn revoke_delegation(
&self,
id: Uuid,
relay: &str,
generation: i64,
) -> Result<(), AuthorityError> {
let relay = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?;
let result=sqlx::query("UPDATE push_gateway_delegations SET generation=$3,revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation<$3").bind(id).bind(relay).bind(generation).execute(&self.pool).await.map_err(db)?;
if result.rows_affected() != 1 {
return Err(AuthorityError::Rejected);
}
Ok(())
}
async fn revoke_installation(
&self,
id: Uuid,
expected: i64,
new: i64,
) -> Result<(), AuthorityError> {
if new != expected.checked_add(1).ok_or(AuthorityError::Rejected)? {
return Err(AuthorityError::Rejected);
}
let result=sqlx::query("UPDATE push_gateway_installations SET endpoint_epoch=$3,revoked_at=now(),updated_at=now() WHERE id=$1 AND endpoint_epoch=$2 AND revoked_at IS NULL").bind(id).bind(expected).bind(new).execute(&self.pool).await.map_err(db)?;
if result.rows_affected() != 1 {
return Err(AuthorityError::Rejected);
}
Ok(())
}
async fn authorize_delivery(
&self,
did: Uuid,
relay: &str,
epoch: i64,
generation: i64,
event_id: &str,
request_id: Uuid,
request_expires_at: i64,
quota_window_seconds: i64,
quota_max_deliveries: i64,
now: i64,
) -> Result<DeliveryPermit, AuthorityError> {
let relay_bytes = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?;
let event_bytes = hex::decode(event_id).map_err(|_| AuthorityError::Rejected)?;
if event_bytes.len() != 32 {
return Err(AuthorityError::Rejected);
}
let mut tx = self.pool.begin().await.map_err(db)?;
// Every authority mutation locks installation before delegation. Keep
// this order here to avoid delivery-vs-refresh deadlocks.
let i = sqlx::query(
"SELECT app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at,revoked_at
FROM push_gateway_installations
WHERE id=(SELECT installation_id FROM push_gateway_delegations WHERE id=$1)
FOR UPDATE",
)
.bind(did)
.fetch_optional(&mut *tx)
.await
.map_err(db)?
.ok_or(AuthorityError::Rejected)?;
if i.try_get::<Option<DateTime<Utc>>, _>("revoked_at")
.map_err(db)?
.is_some()
|| i.try_get::<i64, _>("endpoint_epoch").map_err(db)? != epoch
|| i.try_get::<DateTime<Utc>, _>("expires_at").map_err(db)? < at(now)?
{
return Err(AuthorityError::Rejected);
}
let d = sqlx::query(
"SELECT installation_id,expires_at FROM push_gateway_delegations
WHERE id=$1 AND relay_pubkey=$2 AND endpoint_epoch=$3 AND generation=$4
AND revoked_at IS NULL AND not_before<=$5 AND expires_at>=$5
FOR UPDATE",
)
.bind(did)
.bind(&relay_bytes)
.bind(epoch)
.bind(generation)
.bind(at(now)?)
.fetch_optional(&mut *tx)
.await
.map_err(db)?
.ok_or(AuthorityError::Rejected)?;
let installation_id: Uuid = d.try_get("installation_id").map_err(db)?;
let authority = DeliveryAuthority {
delegation_id: did,
installation_id,
relay_pubkey: relay.to_owned(),
profile: profile(i.try_get("app_profile").map_err(db)?)?,
token_ciphertext: i.try_get("token_ciphertext").map_err(db)?,
endpoint_epoch: epoch,
generation,
expires_at: ts(d.try_get("expires_at").map_err(db)?),
};
if request_expires_at < now || request_expires_at > authority.expires_at {
return Err(AuthorityError::Rejected);
}
if quota_window_seconds < 1 || quota_max_deliveries < 1 {
return Err(AuthorityError::Unavailable);
}
let fingerprint: Vec<u8> = i.try_get("token_fingerprint").map_err(db)?;
let quota = sqlx::query("INSERT INTO push_gateway_endpoint_quotas(token_fingerprint,window_started_at,admitted) VALUES($1,$2,1) ON CONFLICT(token_fingerprint) DO UPDATE SET window_started_at=CASE WHEN push_gateway_endpoint_quotas.window_started_at <= $2 - make_interval(secs => $3::double precision) THEN $2 ELSE push_gateway_endpoint_quotas.window_started_at END, admitted=CASE WHEN push_gateway_endpoint_quotas.window_started_at <= $2 - make_interval(secs => $3::double precision) THEN 1 ELSE push_gateway_endpoint_quotas.admitted + 1 END, updated_at=now() WHERE push_gateway_endpoint_quotas.window_started_at <= $2 - make_interval(secs => $3::double precision) OR push_gateway_endpoint_quotas.admitted < $4")
.bind(fingerprint).bind(at(now)?).bind(quota_window_seconds).bind(quota_max_deliveries).execute(&mut *tx).await.map_err(db)?;
if quota.rows_affected() != 1 {
return Err(AuthorityError::Rejected);
}
let auth_inserted = sqlx::query("INSERT INTO push_gateway_delivery_auth_replays(relay_pubkey,auth_event_id,expires_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING")
.bind(&relay_bytes).bind(event_bytes).bind(at(request_expires_at)?).execute(&mut *tx).await.map_err(db)?;
let request_inserted = sqlx::query("INSERT INTO push_gateway_delivery_request_replays(relay_pubkey,request_id,expires_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING")
.bind(&relay_bytes).bind(request_id).bind(at(request_expires_at)?).execute(&mut *tx).await.map_err(db)?;
if auth_inserted.rows_affected() != 1 || request_inserted.rows_affected() != 1 {
return Err(AuthorityError::Rejected);
}
tx.commit().await.map_err(db)?;
Ok(DeliveryPermit::new(authority, relay.to_owned(), request_id))
}
async fn finish_delivery(
&self,
permit: DeliveryPermit,
disposition: DeliveryDisposition,
) -> Result<(), AuthorityError> {
if disposition == DeliveryDisposition::Retryable {
sqlx::query("DELETE FROM push_gateway_delivery_request_replays WHERE relay_pubkey=$1 AND request_id=$2")
.bind(hex::decode(permit.relay_pubkey).map_err(|_| AuthorityError::Unavailable)?)
.bind(permit.request_id)
.execute(&self.pool)
.await
.map_err(db)?;
}
Ok(())
}
async fn reap_expired(&self, now: i64) -> Result<(), AuthorityError> {
let mut tx = self.pool.begin().await.map_err(db)?;
sqlx::query("DELETE FROM push_gateway_challenges WHERE expires_at < $1")
.bind(at(now)?)
.execute(&mut *tx)
.await
.map_err(db)?;
sqlx::query("DELETE FROM push_gateway_delivery_auth_replays WHERE expires_at < $1")
.bind(at(now)?)
.execute(&mut *tx)
.await
.map_err(db)?;
sqlx::query("DELETE FROM push_gateway_delivery_request_replays WHERE expires_at < $1")
.bind(at(now)?)
.execute(&mut *tx)
.await
.map_err(db)?;
sqlx::query(
"DELETE FROM push_gateway_endpoint_quotas WHERE updated_at < $1 - interval '1 day'",
)
.bind(at(now)?)
.execute(&mut *tx)
.await
.map_err(db)?;
// A parent may become retention-eligible before an otherwise-active
// child. Parent eligibility must therefore reap every child first;
// otherwise the installation delete violates the delegation FK and
// rolls back all cleanup in this transaction.
sqlx::query(
"DELETE FROM push_gateway_delegations d
WHERE d.expires_at < $1
OR d.revoked_at < $1 - interval '1 day'
OR EXISTS (
SELECT 1 FROM push_gateway_installations i
WHERE i.id = d.installation_id
AND (i.expires_at < $1 OR i.revoked_at < $1 - interval '1 day')
)",
)
.bind(at(now)?)
.execute(&mut *tx)
.await
.map_err(db)?;
sqlx::query("DELETE FROM push_gateway_installations WHERE expires_at < $1 OR revoked_at < $1 - interval '1 day'")
.bind(at(now)?).execute(&mut *tx).await.map_err(db)?;
tx.commit().await.map_err(db)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::{postgres::PgPoolOptions, AssertSqlSafe};
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
#[tokio::test]
#[ignore = "requires PostgreSQL with CREATEDB/CREATEROLE"]
async fn readiness_requires_migrated_schema_dml_and_no_ddl() {
let admin_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
let admin = PgPoolOptions::new()
.max_connections(1)
.connect(&admin_url)
.await
.expect("connect as PostgreSQL test administrator");
let suffix = Uuid::new_v4().simple().to_string();
let database = format!("push_ready_{suffix}");
let runtime_role = format!("push_runtime_{suffix}");
sqlx::query(AssertSqlSafe(format!(
"CREATE ROLE {runtime_role} LOGIN PASSWORD 'runtime_test'"
)))
.execute(&admin)
.await
.expect("create runtime role");
sqlx::query(AssertSqlSafe(format!("CREATE DATABASE {database}")))
.execute(&admin)
.await
.expect("create dedicated gateway database");
let mut admin_database_url = url::Url::parse(&admin_url).expect("parse PostgreSQL URL");
admin_database_url.set_path(&database);
let mut runtime_database_url = admin_database_url.clone();
runtime_database_url
.set_username(&runtime_role)
.expect("set runtime username");
runtime_database_url
.set_password(Some("runtime_test"))
.expect("set runtime password");
let runtime_pool = PgPoolOptions::new()
.max_connections(1)
.connect(runtime_database_url.as_str())
.await
.expect("connect as runtime role");
let runtime = PostgresAuthorityStore::new(runtime_pool.clone());
assert!(
runtime.ready().await.is_err(),
"empty database is not ready"
);
let migration_pool = PgPoolOptions::new()
.max_connections(1)
.connect(admin_database_url.as_str())
.await
.expect("connect migration role to dedicated database");
PostgresAuthorityStore::apply_migrations_and_grants(&migration_pool, &runtime_role)
.await
.expect("migrate and grant runtime role");
assert!(
runtime.ready().await.is_ok(),
"migrated least-privilege runtime is ready"
);
assert!(
sqlx::query("CREATE TABLE forbidden_runtime_ddl(id INT)")
.execute(&runtime_pool)
.await
.is_err(),
"runtime role cannot create tables"
);
sqlx::query(AssertSqlSafe(format!(
"REVOKE DELETE ON push_gateway_installations FROM {runtime_role}"
)))
.execute(&migration_pool)
.await
.expect("remove one required DML privilege");
assert!(
runtime.ready().await.is_err(),
"missing DML privilege is not ready"
);
runtime_pool.close().await;
migration_pool.close().await;
sqlx::query(AssertSqlSafe(format!("DROP DATABASE {database}")))
.execute(&admin)
.await
.expect("drop test database");
sqlx::query(AssertSqlSafe(format!("DROP ROLE {runtime_role}")))
.execute(&admin)
.await
.expect("drop test role");
}
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn reaper_deletes_active_child_of_retention_eligible_revoked_installation() {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
let pool = PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await
.expect("connect to PostgreSQL test database");
let schema = format!("push_reaper_{}", Uuid::new_v4().simple());
sqlx::query(AssertSqlSafe(format!("CREATE SCHEMA {schema}")))
.execute(&pool)
.await
.expect("create isolated test schema");
sqlx::query(AssertSqlSafe(format!("SET search_path TO {schema}")))
.execute(&pool)
.await
.expect("select isolated test schema");
sqlx::raw_sql(
"CREATE TABLE push_gateway_challenges (expires_at TIMESTAMPTZ NOT NULL);
CREATE TABLE push_gateway_delivery_auth_replays (expires_at TIMESTAMPTZ NOT NULL);
CREATE TABLE push_gateway_delivery_request_replays (expires_at TIMESTAMPTZ NOT NULL);
CREATE TABLE push_gateway_endpoint_quotas (updated_at TIMESTAMPTZ NOT NULL);
CREATE TABLE push_gateway_installations (
id UUID PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ
);
CREATE TABLE push_gateway_delegations (
id UUID PRIMARY KEY,
installation_id UUID NOT NULL REFERENCES push_gateway_installations(id),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ
);",
)
.execute(&pool)
.await
.expect("create authority retention tables");
let now = Utc::now();
let installation_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO push_gateway_installations(id, expires_at, revoked_at)
VALUES ($1, $2, $3)",
)
.bind(installation_id)
.bind(now + chrono::Duration::days(30))
.bind(now - chrono::Duration::days(2))
.execute(&pool)
.await
.expect("insert retention-eligible revoked installation");
sqlx::query(
"INSERT INTO push_gateway_delegations(id, installation_id, expires_at, revoked_at)
VALUES ($1, $2, $3, NULL)",
)
.bind(Uuid::new_v4())
.bind(installation_id)
.bind(now + chrono::Duration::days(7))
.execute(&pool)
.await
.expect("insert active future-expiring child delegation");
PostgresAuthorityStore::new(pool.clone())
.reap_expired(now.timestamp())
.await
.expect("reaper must delete the child before its revoked parent");
let delegations: i64 = sqlx::query_scalar("SELECT count(*) FROM push_gateway_delegations")
.fetch_one(&pool)
.await
.expect("count delegations");
let installations: i64 =
sqlx::query_scalar("SELECT count(*) FROM push_gateway_installations")
.fetch_one(&pool)
.await
.expect("count installations");
assert_eq!(delegations, 0);
assert_eq!(installations, 0);
sqlx::query("SET search_path TO public")
.execute(&pool)
.await
.expect("restore public schema");
sqlx::query(AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE")))
.execute(&pool)
.await
.expect("drop isolated test schema");
}
// Full authority schema in a private search_path so a multi-connection pool
// exercises the real PK/UNIQUE replay fences that the memory store's single
// mutex cannot. Returns (pool, schema) for teardown.
async fn full_schema(max_connections: u32) -> (PgPool, String) {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
let schema = format!("push_admit_{}", Uuid::new_v4().simple());
let bootstrap = PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await
.expect("connect to PostgreSQL test database");
sqlx::query(AssertSqlSafe(format!("CREATE SCHEMA {schema}")))
.execute(&bootstrap)
.await
.expect("create isolated test schema");
bootstrap.close().await;
// search_path is per-session, so pin it on every pooled connection.
let set_path = format!("SET search_path TO {schema}");
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.after_connect(move |conn, _| {
let set_path = set_path.clone();
Box::pin(async move {
sqlx::query(AssertSqlSafe(set_path)).execute(conn).await?;
Ok(())
})
})
.connect(&database_url)
.await
.expect("connect isolated-schema pool");
// Real DDL from migration 0010 (minus the _operator_global_tables audit
// insert, which lives outside the isolated schema).
sqlx::raw_sql(
"CREATE TABLE push_gateway_installations (
id UUID PRIMARY KEY,
app_attest_key_id BYTEA NOT NULL UNIQUE,
app_attest_public_key BYTEA NOT NULL,
assertion_counter BIGINT NOT NULL,
app_profile TEXT NOT NULL,
token_ciphertext BYTEA NOT NULL,
token_fingerprint BYTEA NOT NULL CHECK (length(token_fingerprint) = 32),
endpoint_epoch BIGINT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (app_profile, token_fingerprint)
);
CREATE TABLE push_gateway_delegations (
id UUID PRIMARY KEY,
installation_id UUID NOT NULL REFERENCES push_gateway_installations(id),
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
endpoint_epoch BIGINT NOT NULL,
generation BIGINT NOT NULL,
not_before TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (installation_id, relay_pubkey)
);
CREATE TABLE push_gateway_endpoint_quotas (
token_fingerprint BYTEA PRIMARY KEY CHECK (length(token_fingerprint) = 32),
window_started_at TIMESTAMPTZ NOT NULL,
admitted BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE push_gateway_delivery_auth_replays (
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
auth_event_id BYTEA NOT NULL CHECK (length(auth_event_id) = 32),
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (relay_pubkey, auth_event_id)
);
CREATE TABLE push_gateway_delivery_request_replays (
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
request_id UUID NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (relay_pubkey, request_id)
);",
)
.execute(&pool)
.await
.expect("create authority admission tables");
(pool, schema)
}
const RELAY_HEX: &str = "11111111111111111111111111111111111111111111111111111111111111aa";
const DELEGATION_ID: u128 = 2;
// One installation + one live delegation that admits at now=1_000.
async fn install_authority(pool: &PgPool) {
let now = Utc::now();
sqlx::query(
"INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at)
VALUES ($1,$2,$3,0,'buzz-ios-production',$4,$5,1,$6)",
)
.bind(Uuid::from_u128(1))
.bind(vec![1u8])
.bind(vec![2u8; 33])
.bind(vec![3u8])
.bind(vec![4u8; 32])
.bind(now + chrono::Duration::days(30))
.execute(pool)
.await
.expect("insert installation");
sqlx::query(
"INSERT INTO push_gateway_delegations(id,installation_id,relay_pubkey,endpoint_epoch,generation,not_before,expires_at,revoked_at)
VALUES ($1,$2,$3,1,1,$4,$5,NULL)",
)
.bind(Uuid::from_u128(DELEGATION_ID))
.bind(Uuid::from_u128(1))
.bind(hex::decode(RELAY_HEX).unwrap())
.bind(now - chrono::Duration::days(1))
.bind(now + chrono::Duration::days(7))
.execute(pool)
.await
.expect("insert delegation");
}
fn admit<'a>(
store: &'a PostgresAuthorityStore,
event_hex: &'a str,
request_id: Uuid,
) -> impl std::future::Future<Output = Result<DeliveryPermit, AuthorityError>> + 'a {
admit_with_quota(store, event_hex, request_id, 10)
}
fn admit_with_quota<'a>(
store: &'a PostgresAuthorityStore,
event_hex: &'a str,
request_id: Uuid,
quota_max_deliveries: i64,
) -> impl std::future::Future<Output = Result<DeliveryPermit, AuthorityError>> + 'a {
let now = Utc::now().timestamp();
store.authorize_delivery(
Uuid::from_u128(DELEGATION_ID),
RELAY_HEX,
1,
1,
event_hex,
request_id,
now + 300,
60,
quota_max_deliveries,
now,
)
}
// Two concurrent admissions colliding on the same (relay,request_id) PK must
// admit exactly once; the loser rejects with its whole tx rolled back, so
// quota is charged once and the auth-event fence is not consumed by the loser.
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn concurrent_same_request_id_admits_exactly_once() {
let (pool, schema) = full_schema(4).await;
install_authority(&pool).await;
let store = PostgresAuthorityStore::new(pool.clone());
let request_id = Uuid::new_v4();
let event_a = "22".repeat(32);
let event_b = "33".repeat(32);
let (a, b) = tokio::join!(
admit(&store, &event_a, request_id),
admit(&store, &event_b, request_id),
);
assert_eq!(
[a.is_ok(), b.is_ok()].iter().filter(|ok| **ok).count(),
1,
"exactly one concurrent same-request_id admission may win"
);
let requests: i64 =
sqlx::query_scalar("SELECT count(*) FROM push_gateway_delivery_request_replays")
.fetch_one(&pool)
.await
.expect("count request replays");
assert_eq!(requests, 1, "winner leaves exactly one request-id fence");
let auth_events: i64 =
sqlx::query_scalar("SELECT count(*) FROM push_gateway_delivery_auth_replays")
.fetch_one(&pool)
.await
.expect("count auth replays");
assert_eq!(auth_events, 1, "loser's auth-event insert rolled back");
let admitted: i64 = sqlx::query_scalar("SELECT admitted FROM push_gateway_endpoint_quotas")
.fetch_one(&pool)
.await
.expect("read quota");
assert_eq!(admitted, 1, "loser's quota reservation rolled back");
pool.close().await;
drop_schema(&schema).await;
}
// Red-team (Tyler's thorough pass): quota ceiling under concurrency. Two
// admissions for the SAME endpoint fingerprint but DISTINCT request_ids and
// DISTINCT auth events — so neither replay PK fence can gate them; the only
// thing standing between the caller and over-admission is the quota upsert's
// `WHERE ... admitted < $4` predicate. With max=1 the two admissions race for
// a single slot. A snapshot-evaluated predicate (reading admitted=0 in both
// txns before either commits) would admit BOTH and burn the ceiling; the
// correct behavior relies on Postgres re-checking the ON CONFLICT DO UPDATE
// predicate against the row it just locked, so the loser sees admitted=1,
// fails `1 < 1`, updates zero rows, and rejects. Exactly one Ok, and the
// persisted counter must never exceed the ceiling.
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn concurrent_admissions_never_over_admit_past_quota_ceiling() {
let (pool, schema) = full_schema(4).await;
install_authority(&pool).await;
let store = PostgresAuthorityStore::new(pool.clone());
let event_a = "22".repeat(32);
let event_b = "33".repeat(32);
let (a, b) = tokio::join!(
admit_with_quota(&store, &event_a, Uuid::new_v4(), 1),
admit_with_quota(&store, &event_b, Uuid::new_v4(), 1),
);
assert_eq!(
[a.is_ok(), b.is_ok()].iter().filter(|ok| **ok).count(),
1,
"quota ceiling of 1 admits exactly one of two concurrent attempts"
);
let admitted: i64 = sqlx::query_scalar("SELECT admitted FROM push_gateway_endpoint_quotas")
.fetch_one(&pool)
.await
.expect("read quota");
assert_eq!(
admitted, 1,
"persisted admitted counter must never exceed the ceiling under a race"
);
// The loser's whole tx rolled back: its distinct auth event is not fenced.
let auth_events: i64 =
sqlx::query_scalar("SELECT count(*) FROM push_gateway_delivery_auth_replays")
.fetch_one(&pool)
.await
.expect("count auth replays");
assert_eq!(auth_events, 1, "rejected admission consumes no auth fence");
let requests: i64 =
sqlx::query_scalar("SELECT count(*) FROM push_gateway_delivery_request_replays")
.fetch_one(&pool)
.await
.expect("count request replays");
assert_eq!(requests, 1, "rejected admission consumes no request fence");
pool.close().await;
drop_schema(&schema).await;
}
// Red-team: Retryable release is unconditional (deletes the request-id row on
// the pool, not inside a tx). Attack the window where a losing delivery's
// release races a fresh admission that legitimately re-took the same
// request_id — could the stale DELETE punch a hole in the live fence? It
// cannot: the DELETE keys on (relay_pubkey, request_id) with no ownership
// token, but the fence it would remove is exactly the one the retrying caller
// is entitled to free, and any *subsequent* admission re-inserts its own row.
// Concretely: admit R, Retryable-release R (fence gone), re-admit R (fresh
// fence), then replay the SAME release a second time (a duplicated/late
// finish) — it must delete the NOW-LIVE fence, and the next admission of R
// must still be gated by whatever fence remains. This pins that a duplicated
// Retryable finish is idempotent-safe and never leaves R permanently
// un-fenceable while the delegation is live.
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn duplicated_retryable_release_does_not_permanently_unfence_request_id() {
let (pool, schema) = full_schema(2).await;
install_authority(&pool).await;
let store = PostgresAuthorityStore::new(pool.clone());
let request_id = Uuid::new_v4();
let permit = admit(&store, &"22".repeat(32), request_id)
.await
.expect("first admission");
// Clone the permit's identity into a second release we fire twice.
store
.finish_delivery(permit, DeliveryDisposition::Retryable)
.await
.expect("retryable release frees the fence");
// Re-admit: fresh fence for the same request_id.
let permit2 = admit(&store, &"33".repeat(32), request_id)
.await
.expect("re-admit after release");
// A duplicated/late Retryable finish for the same (relay, request_id)
// deletes the now-live fence — this is the worst case for the
// unconditional DELETE. It must not error, and R must remain re-admittable
// (fence hole is transient, never permanent), which is the honest
// NIP-PL §312 contract: a still-live endpoint gets a fresh job.
store
.finish_delivery(permit2, DeliveryDisposition::Retryable)
.await
.expect("duplicated retryable release is idempotent-safe");
let admitted_again = admit(&store, &"44".repeat(32), request_id).await;
assert!(
admitted_again.is_ok(),
"after any Retryable release the request_id is re-admittable, never permanently unfenceable"
);
// And a Terminal on that live permit re-burns it, closing the window.
store
.finish_delivery(admitted_again.unwrap(), DeliveryDisposition::Terminal)
.await
.expect("terminal finish");
assert!(
admit(&store, &"55".repeat(32), request_id).await.is_err(),
"terminal keeps the fence burned after the release churn"
);
pool.close().await;
drop_schema(&schema).await;
}
// Retryable release must free the real request-id PK: after finish_delivery,
// the same request_id re-admits with a fresh auth event; a Terminal finish
// leaves it burned.
#[tokio::test]
#[ignore = "requires PostgreSQL"]
async fn retryable_release_frees_request_id_on_real_postgres() {
let (pool, schema) = full_schema(2).await;
install_authority(&pool).await;
let store = PostgresAuthorityStore::new(pool.clone());
let request_id = Uuid::new_v4();
let permit = admit(&store, &"22".repeat(32), request_id)
.await
.expect("first admission");
store
.finish_delivery(permit, DeliveryDisposition::Retryable)
.await
.expect("retryable release");
// Same request_id, fresh auth event: released PK admits again.
let permit = admit(&store, &"33".repeat(32), request_id)
.await
.expect("retryable release frees the request-id PK");
// Terminal now burns it: a further re-admit with the same request_id fails.
store
.finish_delivery(permit, DeliveryDisposition::Terminal)
.await
.expect("terminal finish");
assert!(
admit(&store, &"44".repeat(32), request_id).await.is_err(),
"terminal outcome keeps the request-id fence burned"
);
pool.close().await;
drop_schema(&schema).await;
}
async fn drop_schema(schema: &str) {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
let pool = PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await
.expect("connect for teardown");
sqlx::query(AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE")))
.execute(&pool)
.await
.expect("drop isolated test schema");
}
}
@@ -0,0 +1,89 @@
//! JSON parsing that rejects duplicate object members at every nesting depth.
use std::{collections::HashSet, fmt};
use serde::de::{DeserializeOwned, DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor};
use serde_json::Value;
struct StrictValue;
impl<'de> DeserializeSeed<'de> for StrictValue {
type Value = Value;
fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<Value, D::Error> {
deserializer.deserialize_any(self)
}
}
impl<'de> Visitor<'de> for StrictValue {
type Value = Value;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("JSON with unique object keys")
}
fn visit_bool<E>(self, value: bool) -> Result<Value, E> {
Ok(Value::Bool(value))
}
fn visit_i64<E>(self, value: i64) -> Result<Value, E> {
Ok(Value::Number(value.into()))
}
fn visit_u64<E>(self, value: u64) -> Result<Value, E> {
Ok(Value::Number(value.into()))
}
fn visit_f64<E: serde::de::Error>(self, value: f64) -> Result<Value, E> {
serde_json::Number::from_f64(value)
.map(Value::Number)
.ok_or_else(|| E::custom("non-finite number"))
}
fn visit_str<E>(self, value: &str) -> Result<Value, E> {
Ok(Value::String(value.to_owned()))
}
fn visit_string<E>(self, value: String) -> Result<Value, E> {
Ok(Value::String(value))
}
fn visit_unit<E>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_none<E>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_some<D: Deserializer<'de>>(self, deserializer: D) -> Result<Value, D::Error> {
deserializer.deserialize_any(self)
}
fn visit_seq<A: SeqAccess<'de>>(self, mut sequence: A) -> Result<Value, A::Error> {
let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0));
while let Some(value) = sequence.next_element_seed(StrictValue)? {
values.push(value);
}
Ok(Value::Array(values))
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Value, A::Error> {
use serde::de::Error;
let mut seen = HashSet::new();
let mut values = serde_json::Map::new();
while let Some(key) = map.next_key::<String>()? {
if !seen.insert(key.clone()) {
return Err(A::Error::custom("duplicate object member"));
}
values.insert(key, map.next_value_seed(StrictValue)?);
}
Ok(Value::Object(values))
}
}
/// Parse one complete JSON document, rejecting duplicate keys and trailing data.
pub fn from_slice<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, serde_json::Error> {
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
let value = StrictValue.deserialize(&mut deserializer)?;
deserializer.end()?;
T::deserialize(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_duplicate_keys_at_any_depth() {
assert!(from_slice::<Value>(br#"{"v":1,"v":1}"#).is_err());
assert!(from_slice::<Value>(br#"{"wake":{"v":1,"v":1}}"#).is_err());
assert!(from_slice::<Value>(br#"{"v":1} trailing"#).is_err());
}
}
+122
View File
@@ -0,0 +1,122 @@
//! APNs token custody. Ciphertexts are self-describing and can be decrypted by
//! the current key or bounded decrypt-only predecessors during key rotation.
use aes_gcm::{
aead::{rand_core::RngCore, Aead, KeyInit, OsRng},
Aes256Gcm, Nonce,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use std::collections::{HashMap, HashSet};
use thiserror::Error;
const AAD_PREFIX: &[u8] = b"buzz-apns-token-v1:";
const MAX_KEY_ID_BYTES: usize = 32;
const MAX_CIPHERTEXT_BYTES: usize = 2048;
#[derive(Clone)]
pub struct TokenKey {
id: String,
cipher: Aes256Gcm,
}
#[derive(Clone)]
pub struct TokenKeyring {
current: TokenKey,
predecessors: HashMap<String, TokenKey>,
}
#[derive(Debug, Error)]
pub enum TokenError {
#[error("invalid token ciphertext")]
Invalid,
#[error("token keyring is empty or contains duplicate ids")]
InvalidKeyring,
}
impl TokenKey {
pub fn new(id: impl Into<String>, key: &[u8]) -> Result<Self, TokenError> {
let id = id.into();
if id.is_empty()
|| id.len() > MAX_KEY_ID_BYTES
|| !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
{
return Err(TokenError::Invalid);
}
Ok(Self {
id,
cipher: Aes256Gcm::new_from_slice(key).map_err(|_| TokenError::Invalid)?,
})
}
fn aad(&self) -> Vec<u8> {
[AAD_PREFIX, self.id.as_bytes()].concat()
}
}
impl TokenKeyring {
pub fn new(keys: Vec<TokenKey>) -> Result<Self, TokenError> {
let mut keys = keys.into_iter();
let current = keys.next().ok_or(TokenError::InvalidKeyring)?;
let rest: Vec<_> = keys.collect();
let mut ids = HashSet::new();
if !ids.insert(current.id.clone()) || rest.iter().any(|k| !ids.insert(k.id.clone())) {
return Err(TokenError::InvalidKeyring);
}
Ok(Self {
current,
predecessors: rest.into_iter().map(|k| (k.id.clone(), k)).collect(),
})
}
pub fn seal(&self, token: &[u8]) -> Result<Vec<u8>, TokenError> {
if token.is_empty() || token.len() > crate::model::MAX_ENDPOINT_HEX_BYTES {
return Err(TokenError::Invalid);
}
let mut nonce = [0; 12];
OsRng.fill_bytes(&mut nonce);
let mut sealed = nonce.to_vec();
sealed.extend(
self.current
.cipher
.encrypt(
Nonce::from_slice(&nonce),
aes_gcm::aead::Payload {
msg: token,
aad: &self.current.aad(),
},
)
.map_err(|_| TokenError::Invalid)?,
);
let encoded =
format!("{}.{}", self.current.id, URL_SAFE_NO_PAD.encode(sealed)).into_bytes();
if encoded.len() > MAX_CIPHERTEXT_BYTES {
return Err(TokenError::Invalid);
}
Ok(encoded)
}
pub fn open(&self, encoded: &[u8]) -> Result<Vec<u8>, TokenError> {
if encoded.len() > MAX_CIPHERTEXT_BYTES {
return Err(TokenError::Invalid);
}
let encoded = std::str::from_utf8(encoded).map_err(|_| TokenError::Invalid)?;
let (id, body) = encoded.split_once('.').ok_or(TokenError::Invalid)?;
let key = if id == self.current.id {
&self.current
} else {
self.predecessors.get(id).ok_or(TokenError::Invalid)?
};
let bytes = URL_SAFE_NO_PAD
.decode(body)
.map_err(|_| TokenError::Invalid)?;
if bytes.len() < 13 {
return Err(TokenError::Invalid);
}
key.cipher
.decrypt(
Nonce::from_slice(&bytes[..12]),
aes_gcm::aead::Payload {
msg: &bytes[12..],
aad: &key.aad(),
},
)
.map_err(|_| TokenError::Invalid)
}
}