use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use serde::Serialize; use crate::errors::AppError; #[derive(Debug, Serialize)] struct VapidClaims { aud: String, exp: usize, sub: String, } /// Send a push notification to a subscriber. pub async fn send_push( private_key_pem: &str, public_key_b64url: &str, endpoint: &str, p256dh_b64url: &str, auth_b64url: &str, payload: &[u8], ) -> Result<(), AppError> { // Decode subscriber keys let p256dh = URL_SAFE_NO_PAD .decode(p256dh_b64url) .map_err(|e| AppError::Internal(format!("Invalid p256dh: {e}")))?; let auth = URL_SAFE_NO_PAD .decode(auth_b64url) .map_err(|e| AppError::Internal(format!("Invalid auth: {e}")))?; // Encrypt payload using RFC 8291 (aes128gcm) let encrypted = ece::encrypt(&p256dh, &auth, payload) .map_err(|e| AppError::Internal(format!("Encryption failed: {e}")))?; // Build VAPID JWT let origin = get_origin(endpoint); let exp = (chrono::Utc::now().timestamp() + 86400) as usize; let claims = VapidClaims { aud: origin, exp, sub: "mailto:admin@ayos.app".to_string(), }; let header = Header::new(Algorithm::ES256); let key = EncodingKey::from_ec_pem(private_key_pem.as_bytes()) .map_err(|e| AppError::Internal(format!("Invalid VAPID key: {e}")))?; let jwt = encode(&header, &claims, &key) .map_err(|e| AppError::Internal(format!("JWT signing failed: {e}")))?; // Send HTTP request to push service let client = reqwest::Client::new(); let response = client .post(endpoint) .header("TTL", "86400") .header("Content-Encoding", "aes128gcm") .header( "Authorization", format!("vapid t={jwt}, k={public_key_b64url}"), ) .header("Content-Type", "application/octet-stream") .body(encrypted) .send() .await .map_err(|e| AppError::Internal(format!("Push send failed: {e}")))?; if response.status().as_u16() == 410 || response.status().as_u16() == 404 { return Err(AppError::NotFound("Subscription expired".to_string())); } if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(AppError::Internal(format!( "Push service returned {status}: {body}" ))); } Ok(()) } fn get_origin(url: &str) -> String { if let Some(idx) = url.find("://") { let rest = &url[idx + 3..]; if let Some(path_idx) = rest.find('/') { url[..idx + 3 + path_idx].to_string() } else { url.to_string() } } else { url.to_string() } } /// Generate a new VAPID key pair. /// Returns (private_key_pem, public_key_base64url). pub fn generate_vapid_keys() -> Result<(String, String), AppError> { use p256::elliptic_curve::sec1::ToEncodedPoint; use p256::pkcs8::EncodePrivateKey; use p256::SecretKey; let secret_key = SecretKey::random(&mut rand::rngs::OsRng); let pem = secret_key .to_pkcs8_pem(p256::pkcs8::LineEnding::LF) .map_err(|e| AppError::Internal(format!("Key generation failed: {e}")))?; let public_key = secret_key.public_key(); let point = public_key.to_encoded_point(false); let public_key_b64url = URL_SAFE_NO_PAD.encode(point.as_bytes()); Ok((pem.to_string(), public_key_b64url)) } /// Load or generate VAPID keys from the database. pub async fn init_vapid_keys(pool: &sqlx::SqlitePool) -> (String, String) { let existing = sqlx::query_as::<_, (String, String)>( "SELECT private_key_pem, public_key_base64url FROM vapid_keys WHERE id = 1", ) .fetch_optional(pool) .await .expect("Failed to query vapid_keys"); if let Some((pem, pub_key)) = existing { return (pem, pub_key); } let (pem, pub_key) = generate_vapid_keys().expect("VAPID key generation failed"); sqlx::query( "INSERT INTO vapid_keys (id, private_key_pem, public_key_base64url) VALUES (1, ?, ?)", ) .bind(&pem) .bind(&pub_key) .execute(pool) .await .expect("Failed to store VAPID keys"); println!("Generated new VAPID keys"); (pem, pub_key) }