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
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:
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user