From 6636816970c9e0b950092bd3dc2f3db9c0c25db6 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 7 May 2026 00:09:19 -0300 Subject: [PATCH 1/5] style: apply cargo fmt across repo Reorders imports and rewraps long lines per default rustfmt. No functional changes. Touched files were unrelated to in-flight feature work but had drifted from rustfmt; folding them into a single formatting commit keeps subsequent feature diffs reviewable. --- src/api/mod.rs | 2 +- src/api/notify.rs | 42 ++++----- src/api/rate_limit.rs | 43 +++++---- src/crypto/mod.rs | 193 ++++++++++++++++++++++++++-------------- src/push/fcm.rs | 81 +++++++++-------- src/push/unifiedpush.rs | 22 ++--- src/store/mod.rs | 22 ++--- 7 files changed, 237 insertions(+), 168 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 0f90830..d0935f3 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,5 @@ -pub mod routes; pub mod notify; pub mod rate_limit; +pub mod routes; #[cfg(test)] pub mod test_support; diff --git a/src/api/notify.rs b/src/api/notify.rs index 1dfd0ca..39d7b86 100644 --- a/src/api/notify.rs +++ b/src/api/notify.rs @@ -10,10 +10,10 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use std::sync::Arc; -use governor::clock::Clock; use crate::api::rate_limit::rate_limited_response; use crate::api::routes::AppState; use crate::utils::log_pubkey::log_pubkey; +use governor::clock::Clock; /// Request body for POST /api/notify. /// @@ -57,8 +57,7 @@ pub async fn notify_token( warn!("notify: invalid trade_pubkey format"); return HttpResponse::BadRequest().json(NotifyError { success: false, - message: "Invalid trade_pubkey format (expected 64 hex characters)" - .to_string(), + message: "Invalid trade_pubkey format (expected 64 hex characters)".to_string(), }); } @@ -96,14 +95,8 @@ pub async fn notify_token( // CONC-2-safe: get() drops the RwLock before returning. if let Some(token) = token_store.get(&pubkey).await { match dispatcher.dispatch_silent(&token).await { - Ok(_outcome) => info!( - "notify: dispatched pk={}", - task_log_pk - ), - Err(e) => warn!( - "notify: dispatch failed pk={} err={}", - task_log_pk, e - ), + Ok(_outcome) => info!("notify: dispatched pk={}", task_log_pk), + Err(e) => warn!("notify: dispatch failed pk={} err={}", task_log_pk, e), } } // None case (pubkey not registered): silently no-op. @@ -138,8 +131,7 @@ pub async fn request_id_mw( res.headers_mut().insert( HeaderName::from_static("x-request-id"), - HeaderValue::from_str(&id) - .expect("uuid string is always valid header value"), + HeaderValue::from_str(&id).expect("uuid string is always valid header value"), ); Ok(res) } @@ -147,14 +139,14 @@ pub async fn request_id_mw( #[cfg(test)] mod tests { use super::*; - use actix_web::{http::StatusCode, test, web, App}; - use crate::api::routes::configure; use crate::api::rate_limit::TrustProxyHeaders; + use crate::api::routes::configure; use crate::api::test_support::{ - make_app_state, make_test_components, build_test_actix_app, - register_test_pubkey, StubPushService, TEST_PUBKEY, TEST_PUBKEY_2, + build_test_actix_app, make_app_state, make_test_components, register_test_pubkey, + StubPushService, TEST_PUBKEY, TEST_PUBKEY_2, }; use crate::store::Platform; + use actix_web::{http::StatusCode, test, web, App}; use std::sync::Arc; use uuid::Uuid; @@ -215,7 +207,11 @@ mod tests { .to_request(); let resp = test::call_service(&app, req).await; - assert_eq!(resp.status(), StatusCode::ACCEPTED, "anti-CRIT-2 always-202"); + assert_eq!( + resp.status(), + StatusCode::ACCEPTED, + "anti-CRIT-2 always-202" + ); for _ in 0..20 { tokio::task::yield_now().await; @@ -283,8 +279,14 @@ mod tests { .expect("x-request-id header MUST be present on every /notify response") .to_str() .unwrap(); - assert_ne!(id_value, "spoofed-by-client-12345", "client value must be overwritten"); - assert!(Uuid::parse_str(id_value).is_ok(), "x-request-id must be UUIDv4 parseable"); + assert_ne!( + id_value, "spoofed-by-client-12345", + "client value must be overwritten" + ); + assert!( + Uuid::parse_str(id_value).is_ok(), + "x-request-id must be UUIDv4 parseable" + ); // 400 path — header MUST also be present. let req = test::TestRequest::post() diff --git a/src/api/rate_limit.rs b/src/api/rate_limit.rs index bae57dc..8e0cb70 100644 --- a/src/api/rate_limit.rs +++ b/src/api/rate_limit.rs @@ -108,9 +108,7 @@ pub async fn per_ip_rate_limit_mw( req: ServiceRequest, next: Next, ) -> Result, Error> { - let limiter = req - .app_data::>>() - .cloned(); + let limiter = req.app_data::>>().cloned(); let limiter = match limiter { Some(l) => l, @@ -214,18 +212,14 @@ pub fn start_keyed_limiter_cleanup_task( #[cfg(test)] mod tests { use super::*; - use std::sync::{Arc, Mutex}; - use std::num::NonZeroU32; - use std::time::Duration; - use actix_web::{http::StatusCode, test as atest, web, App}; - use governor::{ - clock::FakeRelativeClock, - state::keyed::HashMapStateStore, - Quota, RateLimiter, - }; use crate::api::test_support::{ - make_test_components, build_test_actix_app, seed_hex_pubkey, TEST_PUBKEY, + build_test_actix_app, make_test_components, seed_hex_pubkey, TEST_PUBKEY, }; + use actix_web::{http::StatusCode, test as atest, web, App}; + use governor::{clock::FakeRelativeClock, state::keyed::HashMapStateStore, Quota, RateLimiter}; + use std::num::NonZeroU32; + use std::sync::{Arc, Mutex}; + use std::time::Duration; /// LIMIT-06: above the cap, the callback fires with the actual length. #[test] @@ -233,7 +227,11 @@ mod tests { let calls: Mutex> = Mutex::new(Vec::new()); check_soft_cap(5, 2, |n| calls.lock().unwrap().push(n)); let captured = calls.lock().unwrap().clone(); - assert_eq!(captured, vec![5], "callback must fire exactly once with len=5"); + assert_eq!( + captured, + vec![5], + "callback must fire exactly once with len=5" + ); } /// LIMIT-06 boundary: at the cap (strict >), the callback does NOT fire. @@ -244,7 +242,11 @@ mod tests { check_soft_cap(2, 2, |n| calls.lock().unwrap().push(n)); check_soft_cap(1, 2, |n| calls.lock().unwrap().push(n)); let captured = calls.lock().unwrap().clone(); - assert!(captured.is_empty(), "boundary: len <= soft_cap MUST NOT fire (got {:?})", captured); + assert!( + captured.is_empty(), + "boundary: len <= soft_cap MUST NOT fire (got {:?})", + captured + ); } /// D-24 #5: Per-IP 429 boundary. @@ -531,11 +533,7 @@ mod tests { HashMapStateStore, FakeRelativeClock, governor::middleware::NoOpMiddleware<::Instant>, - > = RateLimiter::new( - quota, - HashMapStateStore::default(), - &clock, - ); + > = RateLimiter::new(quota, HashMapStateStore::default(), &clock); for i in 0..10 { let _ = limiter.check_key(&format!("key-{}", i)); @@ -672,8 +670,9 @@ mod tests { break; } } - let iter = first_429_iter - .expect("rightmost-XFF (3.3.3.3) MUST be the rate-limit key — 31+ reqs hit per-IP burst"); + let iter = first_429_iter.expect( + "rightmost-XFF (3.3.3.3) MUST be the rate-limit key — 31+ reqs hit per-IP burst", + ); assert!( iter >= IP_BURST as usize, "rightmost-XFF guard exercised: iter={} must be >= IP_BURST={} (otherwise per-pubkey fired first or leftmost was read)", diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 2814ecd..6c5bb73 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -5,7 +5,7 @@ use chacha20poly1305::{ }; use hkdf::Hkdf; use log::{debug, error}; -use secp256k1::{PublicKey, SecretKey, Secp256k1}; +use secp256k1::{PublicKey, Secp256k1, SecretKey}; use serde::Serialize; use sha2::Sha256; @@ -19,7 +19,8 @@ const PADDED_PAYLOAD_SIZE: usize = 220; const EPHEMERAL_PUBKEY_SIZE: usize = 33; const NONCE_SIZE: usize = 12; const AUTH_TAG_SIZE: usize = 16; -pub const ENCRYPTED_TOKEN_SIZE: usize = EPHEMERAL_PUBKEY_SIZE + NONCE_SIZE + PADDED_PAYLOAD_SIZE + AUTH_TAG_SIZE; +pub const ENCRYPTED_TOKEN_SIZE: usize = + EPHEMERAL_PUBKEY_SIZE + NONCE_SIZE + PADDED_PAYLOAD_SIZE + AUTH_TAG_SIZE; #[derive(Debug, Clone, PartialEq)] pub enum Platform { @@ -68,15 +69,15 @@ pub struct TokenCrypto { impl TokenCrypto { pub fn new(secret_key_hex: &str) -> Result { let secp = Secp256k1::new(); - - let secret_key_bytes = hex::decode(secret_key_hex) - .map_err(|_| CryptoError::InvalidSecretKey)?; - - let secret_key = SecretKey::from_slice(&secret_key_bytes) - .map_err(|_| CryptoError::InvalidSecretKey)?; - + + let secret_key_bytes = + hex::decode(secret_key_hex).map_err(|_| CryptoError::InvalidSecretKey)?; + + let secret_key = + SecretKey::from_slice(&secret_key_bytes).map_err(|_| CryptoError::InvalidSecretKey)?; + let public_key = PublicKey::from_secret_key(&secp, &secret_key); - + Ok(Self { secret_key, public_key, @@ -100,7 +101,8 @@ impl TokenCrypto { // Extract components let ephemeral_pubkey_bytes = &encrypted_token[0..EPHEMERAL_PUBKEY_SIZE]; - let nonce_bytes = &encrypted_token[EPHEMERAL_PUBKEY_SIZE..EPHEMERAL_PUBKEY_SIZE + NONCE_SIZE]; + let nonce_bytes = + &encrypted_token[EPHEMERAL_PUBKEY_SIZE..EPHEMERAL_PUBKEY_SIZE + NONCE_SIZE]; let ciphertext = &encrypted_token[EPHEMERAL_PUBKEY_SIZE + NONCE_SIZE..]; debug!("Ephemeral pubkey: {}", hex::encode(ephemeral_pubkey_bytes)); @@ -108,11 +110,10 @@ impl TokenCrypto { debug!("Ciphertext length: {}", ciphertext.len()); // Parse ephemeral public key - let ephemeral_pubkey = PublicKey::from_slice(ephemeral_pubkey_bytes) - .map_err(|e| { - error!("Failed to parse ephemeral pubkey: {}", e); - CryptoError::InvalidEphemeralKey - })?; + let ephemeral_pubkey = PublicKey::from_slice(ephemeral_pubkey_bytes).map_err(|e| { + error!("Failed to parse ephemeral pubkey: {}", e); + CryptoError::InvalidEphemeralKey + })?; // Derive shared secret via ECDH let shared_point = secp256k1::ecdh::SharedSecret::new(&ephemeral_pubkey, &self.secret_key); @@ -129,12 +130,10 @@ impl TokenCrypto { .map_err(|_| CryptoError::CipherError)?; let nonce = Nonce::from_slice(nonce_bytes); - let padded_payload = cipher - .decrypt(nonce, ciphertext) - .map_err(|e| { - error!("Decryption failed: {}", e); - CryptoError::DecryptionFailed - })?; + let padded_payload = cipher.decrypt(nonce, ciphertext).map_err(|e| { + error!("Decryption failed: {}", e); + CryptoError::DecryptionFailed + })?; if padded_payload.len() != PADDED_PAYLOAD_SIZE { error!( @@ -154,14 +153,16 @@ impl TokenCrypto { return Err(CryptoError::InvalidTokenLength); } - let platform = Platform::from_byte(platform_byte) - .ok_or(CryptoError::InvalidPlatform)?; + let platform = Platform::from_byte(platform_byte).ok_or(CryptoError::InvalidPlatform)?; let device_token_bytes = &padded_payload[3..3 + token_length]; let device_token = String::from_utf8(device_token_bytes.to_vec()) .map_err(|_| CryptoError::InvalidTokenEncoding)?; - debug!("Decrypted token for platform {:?}, length {}", platform, token_length); + debug!( + "Decrypted token for platform {:?}, length {}", + platform, token_length + ); Ok(DecryptedToken { platform, @@ -245,7 +246,8 @@ impl TokenCrypto { // Extract components let ephemeral_pubkey_bytes = &encrypted_token[0..EPHEMERAL_PUBKEY_SIZE]; - let nonce_bytes = &encrypted_token[EPHEMERAL_PUBKEY_SIZE..EPHEMERAL_PUBKEY_SIZE + NONCE_SIZE]; + let nonce_bytes = + &encrypted_token[EPHEMERAL_PUBKEY_SIZE..EPHEMERAL_PUBKEY_SIZE + NONCE_SIZE]; let ciphertext = &encrypted_token[EPHEMERAL_PUBKEY_SIZE + NONCE_SIZE..]; result.ephemeral_pubkey_hex = hex::encode(ephemeral_pubkey_bytes); @@ -317,12 +319,14 @@ impl TokenCrypto { if let Some(platform) = Platform::from_byte(platform_byte) { result.platform = Some(platform.to_string()); } else { - result.decryption_error = Some(format!("Invalid platform byte: 0x{:02x}", platform_byte)); + result.decryption_error = + Some(format!("Invalid platform byte: 0x{:02x}", platform_byte)); return result; } if token_length > PADDED_PAYLOAD_SIZE - 3 { - result.decryption_error = Some(format!("Token length {} exceeds maximum", token_length)); + result.decryption_error = + Some(format!("Token length {} exceeds maximum", token_length)); return result; } @@ -374,7 +378,9 @@ pub fn encrypt_token_like_client( // ChaCha20-Poly1305 encrypt let cipher = ChaCha20Poly1305::new_from_slice(&encryption_key).unwrap(); let nonce_obj = Nonce::from_slice(nonce); - let ciphertext = cipher.encrypt(nonce_obj, padded_payload.as_slice()).unwrap(); + let ciphertext = cipher + .encrypt(nonce_obj, padded_payload.as_slice()) + .unwrap(); // Format: ephemeral_pubkey(33) || nonce(12) || ciphertext(236) let mut encrypted_token = Vec::with_capacity(ENCRYPTED_TOKEN_SIZE); @@ -429,7 +435,9 @@ pub fn encrypt_token_with_debug( // Encrypt let cipher = ChaCha20Poly1305::new_from_slice(&encryption_key).unwrap(); let nonce_obj = Nonce::from_slice(nonce); - let ciphertext = cipher.encrypt(nonce_obj, padded_payload.as_slice()).unwrap(); + let ciphertext = cipher + .encrypt(nonce_obj, padded_payload.as_slice()) + .unwrap(); // Combine let mut encrypted_token = Vec::with_capacity(ENCRYPTED_TOKEN_SIZE); @@ -514,7 +522,8 @@ mod tests { let crypto = TokenCrypto::new(&hex::encode(server_secret.secret_bytes())).unwrap(); let device_token = "test_fcm_token_12345"; - let encrypted = create_test_encrypted_token(&server_pubkey, Platform::Android, device_token); + let encrypted = + create_test_encrypted_token(&server_pubkey, Platform::Android, device_token); let decrypted = crypto.decrypt_token(&encrypted).unwrap(); assert_eq!(decrypted.platform, Platform::Android); @@ -532,17 +541,21 @@ mod tests { println!("\n=== HKDF Isolated Test ==="); // Known test vector: 32 bytes of shared_x - let shared_x = hex::decode( - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - ).unwrap(); + let shared_x = + hex::decode("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + .unwrap(); println!("Input shared_x: {}", hex::encode(&shared_x)); - println!("HKDF salt: {:?} ({})", + println!( + "HKDF salt: {:?} ({})", String::from_utf8_lossy(HKDF_SALT), - hex::encode(HKDF_SALT)); - println!("HKDF info: {:?} ({})", + hex::encode(HKDF_SALT) + ); + println!( + "HKDF info: {:?} ({})", String::from_utf8_lossy(HKDF_INFO), - hex::encode(HKDF_INFO)); + hex::encode(HKDF_INFO) + ); let hk = Hkdf::::new(Some(HKDF_SALT), &shared_x); let mut encryption_key = [0u8; 32]; @@ -556,7 +569,10 @@ mod tests { // Store expected value for reference let expected_key = hex::encode(&encryption_key); - println!("\n>>> Flutter client should produce this encryption_key: {}", expected_key); + println!( + "\n>>> Flutter client should produce this encryption_key: {}", + expected_key + ); println!(">>> If it differs, the HKDF parameters or implementation differ"); } @@ -569,15 +585,20 @@ mod tests { // Fixed server keypair let server_secret_hex = "1111111111111111111111111111111111111111111111111111111111111111"; - let server_secret = SecretKey::from_slice(&hex::decode(server_secret_hex).unwrap()).unwrap(); + let server_secret = + SecretKey::from_slice(&hex::decode(server_secret_hex).unwrap()).unwrap(); let server_pubkey = PublicKey::from_secret_key(&secp, &server_secret); // Fixed ephemeral keypair (simulating client) - let ephemeral_secret_hex = "2222222222222222222222222222222222222222222222222222222222222222"; - let ephemeral_secret = SecretKey::from_slice(&hex::decode(ephemeral_secret_hex).unwrap()).unwrap(); + let ephemeral_secret_hex = + "2222222222222222222222222222222222222222222222222222222222222222"; + let ephemeral_secret = + SecretKey::from_slice(&hex::decode(ephemeral_secret_hex).unwrap()).unwrap(); // Fixed nonce - let nonce: [u8; 12] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c]; + let nonce: [u8; 12] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, + ]; let device_token = "fcm_test_token_abc123"; let platform = Platform::Android; @@ -592,8 +613,10 @@ mod tests { ); println!("Encrypted token length: {}", encrypted.len()); - println!("Encrypted token (base64): {}", - base64::engine::general_purpose::STANDARD.encode(&encrypted)); + println!( + "Encrypted token (base64): {}", + base64::engine::general_purpose::STANDARD.encode(&encrypted) + ); // Decrypt with server let crypto = TokenCrypto::new(server_secret_hex).unwrap(); @@ -617,22 +640,33 @@ mod tests { // Fixed server keypair - SHARE WITH FLUTTER FOR TESTING let server_secret_hex = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let server_secret = SecretKey::from_slice(&hex::decode(server_secret_hex).unwrap()).unwrap(); + let server_secret = + SecretKey::from_slice(&hex::decode(server_secret_hex).unwrap()).unwrap(); let server_pubkey = PublicKey::from_secret_key(&secp, &server_secret); println!("SERVER_PRIVATE_KEY: {}", server_secret_hex); - println!("SERVER_PUBLIC_KEY: {}", hex::encode(server_pubkey.serialize())); + println!( + "SERVER_PUBLIC_KEY: {}", + hex::encode(server_pubkey.serialize()) + ); // Fixed ephemeral keypair - FLUTTER CLIENT SHOULD USE THIS - let ephemeral_secret_hex = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - let ephemeral_secret = SecretKey::from_slice(&hex::decode(ephemeral_secret_hex).unwrap()).unwrap(); + let ephemeral_secret_hex = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let ephemeral_secret = + SecretKey::from_slice(&hex::decode(ephemeral_secret_hex).unwrap()).unwrap(); let ephemeral_pubkey = PublicKey::from_secret_key(&secp, &ephemeral_secret); println!("\nEPHEMERAL_PRIVATE_KEY: {}", ephemeral_secret_hex); - println!("EPHEMERAL_PUBLIC_KEY: {}", hex::encode(ephemeral_pubkey.serialize())); + println!( + "EPHEMERAL_PUBLIC_KEY: {}", + hex::encode(ephemeral_pubkey.serialize()) + ); // Fixed nonce - let nonce: [u8; 12] = [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x12, 0x34, 0x56, 0x78]; + let nonce: [u8; 12] = [ + 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x12, 0x34, 0x56, 0x78, + ]; println!("\nNONCE: {}", hex::encode(&nonce)); // Test token @@ -658,7 +692,10 @@ mod tests { println!("\n=== FINAL OUTPUT ==="); println!("encrypted_token (hex): {}", debug_info.final_token_hex); - println!("encrypted_token (base64): {}", debug_info.final_token_base64); + println!( + "encrypted_token (base64): {}", + debug_info.final_token_base64 + ); // Now verify server can decrypt let crypto = TokenCrypto::new(server_secret_hex).unwrap(); @@ -709,11 +746,20 @@ mod tests { let shared_ab_bytes = shared_ab.secret_bytes(); let shared_ba_bytes = shared_ba.secret_bytes(); - println!("\nshared_secret(A_priv * B_pub): {}", hex::encode(&shared_ab_bytes)); - println!("shared_secret(B_priv * A_pub): {}", hex::encode(&shared_ba_bytes)); + println!( + "\nshared_secret(A_priv * B_pub): {}", + hex::encode(&shared_ab_bytes) + ); + println!( + "shared_secret(B_priv * A_pub): {}", + hex::encode(&shared_ba_bytes) + ); // They should be equal (ECDH property) - assert_eq!(shared_ab_bytes, shared_ba_bytes, "ECDH shared secrets should match"); + assert_eq!( + shared_ab_bytes, shared_ba_bytes, + "ECDH shared secrets should match" + ); println!("\n✓ Shared secrets match (ECDH working correctly)"); // Check if this looks like raw X coordinate or SHA256 @@ -731,7 +777,10 @@ mod tests { // Compute what SHA256 would give for comparison use sha2::Digest; let sha256_of_shared = sha2::Sha256::digest(&shared_ab_bytes); - println!("\nFor reference - SHA256(shared_x): {}", hex::encode(&sha256_of_shared)); + println!( + "\nFor reference - SHA256(shared_x): {}", + hex::encode(&sha256_of_shared) + ); println!("If Flutter produces this ^^^, they're incorrectly applying SHA256"); } @@ -742,13 +791,18 @@ mod tests { let secp = Secp256k1::new(); let server_secret_hex = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; - let server_secret = SecretKey::from_slice(&hex::decode(server_secret_hex).unwrap()).unwrap(); + let server_secret = + SecretKey::from_slice(&hex::decode(server_secret_hex).unwrap()).unwrap(); let server_pubkey = PublicKey::from_secret_key(&secp, &server_secret); - let ephemeral_secret_hex = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; - let ephemeral_secret = SecretKey::from_slice(&hex::decode(ephemeral_secret_hex).unwrap()).unwrap(); + let ephemeral_secret_hex = + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + let ephemeral_secret = + SecretKey::from_slice(&hex::decode(ephemeral_secret_hex).unwrap()).unwrap(); - let nonce: [u8; 12] = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc]; + let nonce: [u8; 12] = [ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, + ]; // iOS APNs token format (64 hex chars typically) let device_token = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd"; @@ -764,7 +818,10 @@ mod tests { println!("iOS token encrypted successfully"); println!("Platform byte: 0x01 (iOS)"); - println!("encrypted_token (base64): {}", debug_info.final_token_base64); + println!( + "encrypted_token (base64): {}", + debug_info.final_token_base64 + ); let crypto = TokenCrypto::new(server_secret_hex).unwrap(); let encrypted = hex::decode(&debug_info.final_token_hex).unwrap(); @@ -782,12 +839,15 @@ mod tests { let secp = Secp256k1::new(); let server_secret_hex = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; - let server_secret = SecretKey::from_slice(&hex::decode(server_secret_hex).unwrap()).unwrap(); + let server_secret = + SecretKey::from_slice(&hex::decode(server_secret_hex).unwrap()).unwrap(); let server_pubkey = PublicKey::from_secret_key(&secp, &server_secret); // Note: 0xfff...fff is invalid for secp256k1, using a different valid key - let ephemeral_secret_hex = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; - let ephemeral_secret = SecretKey::from_slice(&hex::decode(ephemeral_secret_hex).unwrap()).unwrap(); + let ephemeral_secret_hex = + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + let ephemeral_secret = + SecretKey::from_slice(&hex::decode(ephemeral_secret_hex).unwrap()).unwrap(); let nonce: [u8; 12] = [0x00; 12]; let device_token = "test_debug_token"; @@ -804,7 +864,10 @@ mod tests { let debug_result = crypto.debug_decrypt_token(&encrypted); println!("Debug decrypt result:"); - println!(" ephemeral_pubkey_valid: {}", debug_result.ephemeral_pubkey_valid); + println!( + " ephemeral_pubkey_valid: {}", + debug_result.ephemeral_pubkey_valid + ); println!(" ephemeral_pubkey: {}", debug_result.ephemeral_pubkey_hex); println!(" nonce: {}", debug_result.nonce_hex); println!(" ciphertext_len: {}", debug_result.ciphertext_len); diff --git a/src/push/fcm.rs b/src/push/fcm.rs index 43761ec..2ffd516 100644 --- a/src/push/fcm.rs +++ b/src/push/fcm.rs @@ -1,17 +1,17 @@ use async_trait::async_trait; -use log::{info, error, debug, warn}; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use log::{debug, error, info, warn}; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::sync::Arc; -use tokio::sync::RwLock; -use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use std::fs; +use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::RwLock; +use super::PushService; use crate::config::Config; use crate::store::Platform; -use super::PushService; #[derive(Debug, Deserialize)] struct ServiceAccount { @@ -50,29 +50,26 @@ pub struct FcmPush { impl FcmPush { pub fn new(config: Config, client: Arc) -> Self { let service_account_path = std::env::var("FIREBASE_SERVICE_ACCOUNT_PATH").ok(); - let project_id = std::env::var("FIREBASE_PROJECT_ID") - .unwrap_or_else(|_| "mostro".to_string()); - - let service_account = service_account_path.and_then(|path| { - match fs::read_to_string(&path) { - Ok(content) => { - match serde_json::from_str::(&content) { - Ok(sa) => { - info!("Loaded Firebase service account for {}", sa.client_email); - Some(sa) - } - Err(e) => { - error!("Failed to parse service account JSON: {}", e); - None - } + let project_id = + std::env::var("FIREBASE_PROJECT_ID").unwrap_or_else(|_| "mostro".to_string()); + + let service_account = + service_account_path.and_then(|path| match fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(sa) => { + info!("Loaded Firebase service account for {}", sa.client_email); + Some(sa) } - } + Err(e) => { + error!("Failed to parse service account JSON: {}", e); + None + } + }, Err(e) => { warn!("Could not read service account file {}: {}", path, e); None } - } - }); + }); Self { client, @@ -97,9 +94,7 @@ impl FcmPush { { let cache = self.cached_token.read().await; if let Some(ref cached) = *cache { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH)? - .as_secs(); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); // Refresh 60 seconds before expiry if cached.expires_at > now + 60 { return Ok(cached.token.clone()); @@ -108,12 +103,12 @@ impl FcmPush { } // Need to refresh token - let sa = self.service_account.as_ref() + let sa = self + .service_account + .as_ref() .ok_or("No service account configured")?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH)? - .as_secs(); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); let claims = Claims { iss: sa.client_email.clone(), @@ -128,7 +123,8 @@ impl FcmPush { let jwt = encode(&header, &claims, &key)?; // Exchange JWT for access token - let response = self.client + let response = self + .client .post("https://oauth2.googleapis.com/token") .form(&[ ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), @@ -143,7 +139,7 @@ impl FcmPush { } let token_response: TokenResponse = response.json().await?; - + // Cache the token { let mut cache = self.cached_token.write().await; @@ -153,7 +149,10 @@ impl FcmPush { }); } - info!("Obtained new FCM access token, expires in {}s", token_response.expires_in); + info!( + "Obtained new FCM access token, expires in {}s", + token_response.expires_in + ); Ok(token_response.access_token) } @@ -267,9 +266,13 @@ impl PushService for FcmPush { let payload = Self::build_payload_for_token(device_token); - debug!("Sending FCM to token: {}...", &device_token[..20.min(device_token.len())]); + debug!( + "Sending FCM to token: {}...", + &device_token[..20.min(device_token.len())] + ); - let response = self.client + let response = self + .client .post(&fcm_url) .bearer_auth(&auth_token) .json(&payload) @@ -300,9 +303,13 @@ impl PushService for FcmPush { let payload = Self::build_silent_payload_for_notify(device_token); - debug!("Sending FCM silent to token: {}...", &device_token[..20.min(device_token.len())]); + debug!( + "Sending FCM silent to token: {}...", + &device_token[..20.min(device_token.len())] + ); - let response = self.client + let response = self + .client .post(&fcm_url) .bearer_auth(&auth_token) .json(&payload) diff --git a/src/push/unifiedpush.rs b/src/push/unifiedpush.rs index 59e3be3..089a0f2 100644 --- a/src/push/unifiedpush.rs +++ b/src/push/unifiedpush.rs @@ -1,16 +1,16 @@ use async_trait::async_trait; -use log::{info, error, debug, warn}; +use log::{debug, error, info, warn}; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use tokio::sync::RwLock; use tokio::fs; +use tokio::sync::RwLock; +use super::PushService; use crate::config::Config; use crate::store::Platform; -use super::PushService; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UnifiedPushEndpoint { @@ -118,7 +118,10 @@ impl UnifiedPushService { // Persist to disk self.save_endpoints().await?; - info!("Unregistered UnifiedPush endpoint for device: {}", device_id); + info!( + "Unregistered UnifiedPush endpoint for device: {}", + device_id + ); Ok(()) } } @@ -136,13 +139,12 @@ impl PushService for UnifiedPushService { "timestamp": chrono::Utc::now().timestamp() }); - debug!("Sending UnifiedPush to endpoint: {}...", &device_token[..30.min(device_token.len())]); + debug!( + "Sending UnifiedPush to endpoint: {}...", + &device_token[..30.min(device_token.len())] + ); - let response = self.client - .post(device_token) - .json(&payload) - .send() - .await?; + let response = self.client.post(device_token).json(&payload).send().await?; if response.status().is_success() { info!("UnifiedPush notification sent successfully"); diff --git a/src/store/mod.rs b/src/store/mod.rs index 47caeb7..0d202b0 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -45,12 +45,7 @@ impl TokenStore { } } - pub async fn register( - &self, - trade_pubkey: String, - device_token: String, - platform: Platform, - ) { + pub async fn register(&self, trade_pubkey: String, device_token: String, platform: Platform) { let token = RegisteredToken { device_token, platform, @@ -98,13 +93,15 @@ impl TokenStore { let ttl = chrono::Duration::hours(self.ttl_hours as i64); let initial_count = tokens.len(); - tokens.retain(|_, token| { - now.signed_duration_since(token.registered_at) < ttl - }); + tokens.retain(|_, token| now.signed_duration_since(token.registered_at) < ttl); let removed = initial_count - tokens.len(); if removed > 0 { - info!("Cleaned up {} expired tokens (remaining: {})", removed, tokens.len()); + info!( + "Cleaned up {} expired tokens (remaining: {})", + removed, + tokens.len() + ); } removed @@ -143,9 +140,8 @@ pub struct TokenStoreStats { pub fn start_cleanup_task(store: std::sync::Arc, interval_hours: u64) { tokio::spawn(async move { - let mut interval = tokio::time::interval( - tokio::time::Duration::from_secs(interval_hours * 3600) - ); + let mut interval = + tokio::time::interval(tokio::time::Duration::from_secs(interval_hours * 3600)); loop { interval.tick().await; From 4ae53735d6989f87cfc46f1e387221a249fd80cb Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 7 May 2026 00:10:28 -0300 Subject: [PATCH 2/5] feat(register): filter device registration by trusted Mostro instance pubkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile clients now declare which Mostro instance they use via a new optional `mostro_pubkey` field on POST /api/register. The server checks that value against a compile-time whitelist embedded from config/trusted_mostro_pubkeys.json, mirroring the trusted instances already listed in the mobile app (mobile/lib/core/config/communities.dart). Behaviour: - Empty whitelist => permissive mode; the field is ignored. Keeps the existing JSON shape and lets the binary be built without an explicit list configured. - Populated whitelist => the field MUST be present and must match an entry, otherwise registration is rejected with 403 Forbidden and the fixed body {"success":false,"message":"Mostro instance not trusted"}. Malformed values (length / hex) return 400 with a distinct body. This is an honour-system filter only: there is no cryptographic proof binding the device to the declared instance. It is intended to keep well-behaved clients from arbitrary instances out of the push pipeline and will be hardened once registration carries a daemon-issued signature. The previous MOSTRO_PUBKEY environment variable is removed; it was only used as log context and never as an authors filter on the Nostr listener (the no-author-filter invariant in CLAUDE.md still holds — that path is unchanged). Deployment scripts, .env.example and docs are updated accordingly. Tests cover trusted, untrusted, missing-field, malformed and permissive-mode paths. Existing byte-identical fixtures for /api/register remain green: the new field is request-only and does not affect any response shape. --- .env.example | 7 +- .gitignore | 1 + CLAUDE.md | 28 +++- config/trusted_mostro_pubkeys.json | 7 + deploy-fly.sh | 1 - docs/api.md | 31 +++- docs/configuration.md | 22 ++- docs/deployment.md | 1 - src/api/routes.rs | 222 ++++++++++++++++++++++++++--- src/api/test_support.rs | 84 ++++++++--- src/config.rs | 53 ++----- src/main.rs | 50 +++++-- src/nostr/listener.rs | 23 +-- src/trusted_pubkeys.rs | 53 +++++++ 14 files changed, 460 insertions(+), 123 deletions(-) create mode 100644 config/trusted_mostro_pubkeys.json create mode 100644 src/trusted_pubkeys.rs diff --git a/.env.example b/.env.example index 85c9be5..dd9b51a 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,10 @@ # Nostr Configuration NOSTR_RELAYS=wss://relay.mostro.network -# Mostro daemon public key (hex format, 64 chars) -# This is the pubkey that signs kind 1059 events. -MOSTRO_PUBKEY=82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390 +# The list of trusted Mostro instance pubkeys is now compiled into the binary +# from config/trusted_mostro_pubkeys.json. To add or remove instances, edit +# that file and rebuild. There is no MOSTRO_PUBKEY environment variable +# anymore. # Server Keypair (REQUIRED) # Generate with: openssl rand -hex 32 diff --git a/.gitignore b/.gitignore index 6bed320..3716044 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ target firebase-service-account.json *.json !.planning/**/*.json +!config/trusted_mostro_pubkeys.json # Logs *.log diff --git a/CLAUDE.md b/CLAUDE.md index 619e33c..cdf3e0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ These are the privacy and compatibility invariants of the project. Reintroducing - Inbound `X-Request-Id` is stripped; server generates UUIDv4 per request. - Dispatch happens in a `tokio::spawn` task detached from the response, bounded by `Arc(50)`. -3. **Backwards compatibility of the existing endpoints.** `/api/health`, `/api/info`, `/api/status`, `/api/register`, `/api/unregister` response bodies are byte-identical to fixtures captured before v1.1. Field order on `RegisterResponse` is `success, message, platform`. +3. **Backwards compatibility of the existing endpoints.** `/api/health`, `/api/info`, `/api/status`, `/api/register`, `/api/unregister` response bodies are byte-identical to fixtures captured before v1.1. Field order on `RegisterResponse` is `success, message, platform`. The `mostro_pubkey` field added to `RegisterTokenRequest` is request-only and does not change response shapes. 4. **Token store is in-memory only.** No persistence to disk for `trade_pubkey -> device_token`. UnifiedPush endpoints are the only on-disk state (atomic JSON write to `data/unifiedpush_endpoints.json`). @@ -60,6 +60,7 @@ These are the privacy and compatibility invariants of the project. Reintroducing src/ ├── main.rs # Boot + wiring ├── config.rs # Config::from_env (typed env-var loader) +├── trusted_pubkeys.rs # Compile-time whitelist (include_str! the JSON below) ├── api/ │ ├── routes.rs # /health, /info, /status, /register, /unregister + AppState │ ├── notify.rs # /api/notify handler + request_id_mw @@ -76,8 +77,33 @@ src/ └── utils/ ├── log_pubkey.rs # Salted BLAKE3 keyed hash └── batching.rs # Reserved (unused at runtime) + +config/ +└── trusted_mostro_pubkeys.json # JSON array of 64-hex pubkeys; mirrors mobile/lib/core/config/communities.dart ``` +## Trusted Mostro instance whitelist + +`/api/register` filters registrations against a compile-time whitelist of +trusted Mostro instance pubkeys, embedded into the binary via +`include_str!("../config/trusted_mostro_pubkeys.json")`. The mobile client is +expected to send the pubkey of the selected Mostro instance in the +`mostro_pubkey` field of the registration body. + +- An empty JSON array disables the whitelist (permissive mode); the field + is then ignored. +- A non-empty array activates the filter; missing or unknown + `mostro_pubkey` values are rejected with `403 Forbidden`. Malformed + values (length or hex) return `400 Bad Request`. +- This filter is honour-system only — the device cryptographically proves + nothing about which Mostro instance it actually uses. It will be hardened + in a future phase. Do NOT remove the whitelist code on the basis that it + "isn't really enforcing anything"; it deliberately blocks well-behaved + clients from arbitrary instances and the harder protocol depends on this + field staying in the request shape. +- The previous `MOSTRO_PUBKEY` environment variable has been removed; it + was only used as log context and was never an authors filter. + ## Common commands ```bash diff --git a/config/trusted_mostro_pubkeys.json b/config/trusted_mostro_pubkeys.json new file mode 100644 index 0000000..f466b22 --- /dev/null +++ b/config/trusted_mostro_pubkeys.json @@ -0,0 +1,7 @@ +[ + "00000235a3e904cfe1213a8a54d6f1ec1bef7cc6bfaabd6193e82931ccf1366a", + "0000cc02101ec29eea9ce623258752b9d7da66c27845ed26846dd0b0fc736b40", + "00000978acc594c506976c655b6decbf2d4af25ffdaa6680f2a9568b0a88441b", + "00007cb3305fb972f5cc83f83a8fbca1e64e93c9d1369880a9fd62ef95d23f91", + "82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390" +] diff --git a/deploy-fly.sh b/deploy-fly.sh index 929c502..4d4c6ca 100755 --- a/deploy-fly.sh +++ b/deploy-fly.sh @@ -26,7 +26,6 @@ echo "📝 Configurando secrets..." # Configurar todos los secrets flyctl secrets set \ NOSTR_RELAYS="wss://relay.mostro.network" \ - MOSTRO_PUBKEY="82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390" \ SERVER_PRIVATE_KEY="2dfb72f7e130b4c6f971c5bac364b9f854f2409de51fb53d4dbd3e17bd69b98e" \ FIREBASE_PROJECT_ID="mostro-mobile" \ FIREBASE_SERVICE_ACCOUNT_PATH="/secrets/mostro-mobile-firebase-adminsdk-fbsvc-1ff8f6232c.json" \ diff --git a/docs/api.md b/docs/api.md index fee6a39..a354b79 100644 --- a/docs/api.md +++ b/docs/api.md @@ -69,15 +69,17 @@ Request: { "trade_pubkey": "<64-char hex>", "token": "", - "platform": "android" + "platform": "android", + "mostro_pubkey": "<64-char hex of the Mostro instance>" } ``` -| Field | Type | Description | -|----------------|--------|----------------------------------------------------------| -| `trade_pubkey` | string | 64 hex characters | -| `token` | string | FCM device token, or UnifiedPush endpoint URL | -| `platform` | string | `"android"` or `"ios"` | +| Field | Type | Description | +|-----------------|--------|------------------------------------------------------------------------------------------------------------------------| +| `trade_pubkey` | string | 64 hex characters | +| `token` | string | FCM device token, or UnifiedPush endpoint URL | +| `platform` | string | `"android"` or `"ios"` | +| `mostro_pubkey` | string | 64 hex characters. Optional on the wire; required when the trusted-instance whitelist is non-empty (see below). | Success — `200 OK`: @@ -103,6 +105,23 @@ Possible validation errors: - `trade_pubkey` not 64 hex characters - `token` empty - `platform` not `"android"` or `"ios"` +- `mostro_pubkey` present but not 64 hex characters + +Trusted-instance filter — `403 Forbidden`: + +```json +{ + "success": false, + "message": "Mostro instance not trusted" +} +``` + +Returned when the trusted Mostro instance whitelist is non-empty AND the +client either omitted `mostro_pubkey` or sent a value that is not on the +whitelist. The whitelist is compiled into the binary from +`config/trusted_mostro_pubkeys.json`; see [configuration.md](./configuration.md). +This filter is honour-system: there is no cryptographic proof binding the +device to the declared instance. ## POST /api/unregister diff --git a/docs/configuration.md b/docs/configuration.md index 455974d..a4834a0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,9 +18,24 @@ cp .env.example .env ## Nostr listener -| Variable | Default | Description | -|-----------------|----------------------------------------------------------------------|----------------------------------------------------------| -| `MOSTRO_PUBKEY` | `82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390` | Hex pubkey of the Mostro daemon. Used for log context only — it is NOT applied as an `authors` filter on the listener (privacy invariant; see [architecture.md](./architecture.md)). | +The listener has no instance-specific configuration. It does NOT filter +events by `authors` (privacy invariant; see [architecture.md](./architecture.md)). + +## Trusted Mostro instance whitelist + +The set of Mostro instance pubkeys allowed to register devices is compiled +into the binary from `config/trusted_mostro_pubkeys.json` at build time. + +- The file must contain a JSON array of 64-character hex pubkeys. +- An empty array disables the whitelist (permissive mode); any client may + register without declaring a `mostro_pubkey`. +- A non-empty array activates the filter on `/api/register`: clients must + send a `mostro_pubkey` field whose value matches one of the entries, + otherwise the request is rejected with `403 Forbidden`. +- The file is parsed at startup; malformed JSON or any entry that is not + 64 hex characters causes the process to panic immediately (fail-fast). + +To change the list, edit `config/trusted_mostro_pubkeys.json` and rebuild. ## HTTP server @@ -87,7 +102,6 @@ RUST_LOG=mostro_push_backend=debug,actix_web=info ```bash # Nostr NOSTR_RELAYS=wss://relay.mostro.network -MOSTRO_PUBKEY=82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390 # Server SERVER_HOST=0.0.0.0 diff --git a/docs/deployment.md b/docs/deployment.md index f49a764..3dd1b13 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -34,7 +34,6 @@ Or do it by hand: ```bash flyctl secrets set \ NOSTR_RELAYS="wss://relay.mostro.network" \ - MOSTRO_PUBKEY="82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390" \ FIREBASE_PROJECT_ID="your-project-id" \ FIREBASE_SERVICE_ACCOUNT_PATH="/secrets/firebase-service-account.json" \ FCM_ENABLED="true" \ diff --git a/src/api/routes.rs b/src/api/routes.rs index 1051826..f4a8920 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -1,22 +1,31 @@ use actix_web::middleware::from_fn; use actix_web::{web, HttpResponse, Responder}; -use serde::{Deserialize, Serialize}; use log::{info, warn}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::sync::Arc; use tokio::sync::Semaphore; use crate::api::notify::{notify_token, request_id_mw}; use crate::api::rate_limit::{per_ip_rate_limit_mw, PerPubkeyLimiter}; use crate::push::PushDispatcher; -use crate::store::{TokenStore, TokenStoreStats, Platform}; +use crate::store::{Platform, TokenStore, TokenStoreStats}; use crate::utils::log_pubkey::log_pubkey; -/// Request for registering a plaintext token (Phase 3 - unencrypted) +/// Request for registering a plaintext token (Phase 3 - unencrypted). +/// +/// `mostro_pubkey` is the hex pubkey (64 chars) of the Mostro instance the +/// client is using. It is optional on the wire to keep the JSON shape +/// backward-compatible, but it is REQUIRED in practice when the trusted +/// Mostro pubkey whitelist is non-empty (see `AppState::trusted_mostro_pubkeys`). +/// When the whitelist is empty the field is ignored. #[derive(Deserialize)] pub struct RegisterTokenRequest { pub trade_pubkey: String, pub token: String, pub platform: String, + #[serde(default)] + pub mostro_pubkey: Option, } #[derive(Deserialize)] @@ -46,6 +55,10 @@ pub struct AppState { pub semaphore: Arc, pub notify_log_salt: Arc<[u8; 32]>, pub per_pubkey_limiter: Arc, + /// Whitelist of trusted Mostro instance pubkeys (hex, 64 chars). + /// Empty set disables the whitelist (permissive mode); a populated set + /// activates the filter on `/api/register`. + pub trusted_mostro_pubkeys: Arc>, } pub fn configure(cfg: &mut web::ServiceConfig) { @@ -73,9 +86,7 @@ async fn health_check() -> impl Responder { HttpResponse::Ok().json(serde_json::json!({"status": "ok"})) } -async fn status( - state: web::Data, -) -> impl Responder { +async fn status(state: web::Data) -> impl Responder { let stats = state.token_store.get_stats().await; HttpResponse::Ok().json(StatusResponse { @@ -132,18 +143,62 @@ async fn register_token( warn!("Invalid platform: {}", req.platform); return HttpResponse::BadRequest().json(RegisterResponse { success: false, - message: format!("Invalid platform '{}' (expected 'android' or 'ios')", req.platform), + message: format!( + "Invalid platform '{}' (expected 'android' or 'ios')", + req.platform + ), platform: None, }); } }; + // Trusted Mostro instance whitelist. Empty set => permissive mode + // (mostro_pubkey ignored). Populated set => the client MUST declare a + // mostro_pubkey present in the list, otherwise registration is denied. + // Honor-system filter only: there is no cryptographic proof that the + // device actually uses the declared instance. This will be hardened in a + // future phase when registration carries a signature from the daemon. + if !state.trusted_mostro_pubkeys.is_empty() { + match req.mostro_pubkey.as_deref() { + None => { + warn!("Register denied: mostro_pubkey missing while whitelist active"); + return HttpResponse::Forbidden().json(RegisterResponse { + success: false, + message: "Mostro instance not trusted".to_string(), + platform: None, + }); + } + Some(mostro_pk) => { + if mostro_pk.len() != 64 || hex::decode(mostro_pk).is_err() { + warn!("Invalid mostro_pubkey format"); + return HttpResponse::BadRequest().json(RegisterResponse { + success: false, + message: "Invalid mostro_pubkey format (expected 64 hex characters)" + .to_string(), + platform: None, + }); + } + if !state.trusted_mostro_pubkeys.contains(mostro_pk) { + warn!("Register denied: untrusted Mostro instance"); + return HttpResponse::Forbidden().json(RegisterResponse { + success: false, + message: "Mostro instance not trusted".to_string(), + platform: None, + }); + } + } + } + } + // Store the token directly (no decryption in Phase 3) - state.token_store.register( - req.trade_pubkey.clone(), - req.token.clone(), - platform.clone(), - ).await; + state + .token_store + .register( + req.trade_pubkey.clone(), + req.token.clone(), + platform.clone(), + ) + .await; info!( "Successfully registered {} token pk={}", @@ -194,8 +249,11 @@ async fn unregister_token( #[cfg(test)] mod tests { use super::*; + use crate::api::test_support::{ + build_test_actix_app, make_test_components, make_test_components_with_trusted_whitelist, + TEST_PUBKEY, TEST_PUBKEY_2, TRUSTED_MOSTRO_PUBKEY, UNTRUSTED_MOSTRO_PUBKEY, + }; use actix_web::{http::StatusCode, test as atest}; - use crate::api::test_support::{make_test_components, build_test_actix_app, TEST_PUBKEY, TEST_PUBKEY_2}; /// VERIFY-02 / D-24 #6: /api/register success body is BYTE-IDENTICAL to /// the pre-milestone fixture. RegisterResponse field order @@ -388,7 +446,11 @@ mod tests { .insert_header(("Fly-Client-IP", "8.8.8.8")) .to_request(); let resp = atest::call_service(&app, req).await; - assert_ne!(resp.status(), StatusCode::TOO_MANY_REQUESTS, "/api/info must not 429"); + assert_ne!( + resp.status(), + StatusCode::TOO_MANY_REQUESTS, + "/api/info must not 429" + ); } for _ in 0..50 { @@ -397,7 +459,11 @@ mod tests { .insert_header(("Fly-Client-IP", "8.8.8.8")) .to_request(); let resp = atest::call_service(&app, req).await; - assert_ne!(resp.status(), StatusCode::TOO_MANY_REQUESTS, "/api/status must not 429"); + assert_ne!( + resp.status(), + StatusCode::TOO_MANY_REQUESTS, + "/api/status must not 429" + ); } // /api/register: 50 distinct pubkeys to avoid TokenStore deduplication. @@ -413,7 +479,131 @@ mod tests { })) .to_request(); let resp = atest::call_service(&app, req).await; - assert_ne!(resp.status(), StatusCode::TOO_MANY_REQUESTS, "/api/register must not 429"); + assert_ne!( + resp.status(), + StatusCode::TOO_MANY_REQUESTS, + "/api/register must not 429" + ); } } + + /// Whitelist active, declared mostro_pubkey is in the list -> 200. + #[actix_web::test] + async fn register_with_trusted_mostro_pubkey_succeeds() { + let c = make_test_components_with_trusted_whitelist(); + let app = atest::init_service(build_test_actix_app(c)).await; + + let req = atest::TestRequest::post() + .uri("/api/register") + .set_json(serde_json::json!({ + "trade_pubkey": TEST_PUBKEY, + "token": "test_fcm_token", + "platform": "android", + "mostro_pubkey": TRUSTED_MOSTRO_PUBKEY + })) + .to_request(); + let resp = atest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = atest::read_body(resp).await; + let body_str = std::str::from_utf8(&body).unwrap(); + assert_eq!( + body_str, + r#"{"success":true,"message":"Token registered successfully","platform":"android"}"# + ); + } + + /// Whitelist active, declared mostro_pubkey NOT in the list -> 403. + #[actix_web::test] + async fn register_with_untrusted_mostro_pubkey_returns_403() { + let c = make_test_components_with_trusted_whitelist(); + let app = atest::init_service(build_test_actix_app(c)).await; + + let req = atest::TestRequest::post() + .uri("/api/register") + .set_json(serde_json::json!({ + "trade_pubkey": TEST_PUBKEY, + "token": "test_fcm_token", + "platform": "android", + "mostro_pubkey": UNTRUSTED_MOSTRO_PUBKEY + })) + .to_request(); + let resp = atest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let body = atest::read_body(resp).await; + let body_str = std::str::from_utf8(&body).unwrap(); + assert_eq!( + body_str, + r#"{"success":false,"message":"Mostro instance not trusted"}"# + ); + } + + /// Whitelist active, mostro_pubkey field omitted -> 403 (same body as untrusted). + #[actix_web::test] + async fn register_without_mostro_pubkey_when_whitelist_active_returns_403() { + let c = make_test_components_with_trusted_whitelist(); + let app = atest::init_service(build_test_actix_app(c)).await; + + let req = atest::TestRequest::post() + .uri("/api/register") + .set_json(serde_json::json!({ + "trade_pubkey": TEST_PUBKEY, + "token": "test_fcm_token", + "platform": "android" + })) + .to_request(); + let resp = atest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let body = atest::read_body(resp).await; + let body_str = std::str::from_utf8(&body).unwrap(); + assert_eq!( + body_str, + r#"{"success":false,"message":"Mostro instance not trusted"}"# + ); + } + + /// Whitelist active, mostro_pubkey malformed -> 400 (distinct from 403). + #[actix_web::test] + async fn register_with_malformed_mostro_pubkey_returns_400() { + let c = make_test_components_with_trusted_whitelist(); + let app = atest::init_service(build_test_actix_app(c)).await; + + let req = atest::TestRequest::post() + .uri("/api/register") + .set_json(serde_json::json!({ + "trade_pubkey": TEST_PUBKEY, + "token": "test_fcm_token", + "platform": "android", + "mostro_pubkey": "tooshort" + })) + .to_request(); + let resp = atest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = atest::read_body(resp).await; + let body_str = std::str::from_utf8(&body).unwrap(); + assert_eq!( + body_str, + r#"{"success":false,"message":"Invalid mostro_pubkey format (expected 64 hex characters)"}"# + ); + } + + /// Whitelist empty (default), mostro_pubkey field absent -> 200. Confirms + /// permissive mode does not require the new field. Distinct from + /// register_success_body_is_byte_identical: that test guards the response + /// fixture; this one guards the whitelist-disabled control flow. + #[actix_web::test] + async fn register_without_mostro_pubkey_when_whitelist_empty_succeeds() { + let c = make_test_components(); + let app = atest::init_service(build_test_actix_app(c)).await; + + let req = atest::TestRequest::post() + .uri("/api/register") + .set_json(serde_json::json!({ + "trade_pubkey": TEST_PUBKEY_2, + "token": "test_fcm_token", + "platform": "android" + })) + .to_request(); + let resp = atest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + } } diff --git a/src/api/test_support.rs b/src/api/test_support.rs index d353d5b..62caeb8 100644 --- a/src/api/test_support.rs +++ b/src/api/test_support.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::num::NonZeroU32; use std::sync::Arc; use tokio::sync::{Mutex, Semaphore}; @@ -7,7 +8,9 @@ use async_trait::async_trait; use governor::{Quota, RateLimiter}; use rand::RngCore; -use crate::api::rate_limit::{PerIpLimiter, PerPubkeyLimiter, TrustProxyHeaders, IP_BURST, PUBKEY_BURST}; +use crate::api::rate_limit::{ + PerIpLimiter, PerPubkeyLimiter, TrustProxyHeaders, IP_BURST, PUBKEY_BURST, +}; use crate::api::routes::{configure, AppState}; use crate::push::{PushDispatcher, PushService}; use crate::store::{Platform, TokenStore}; @@ -66,13 +69,25 @@ pub fn test_per_pubkey_quota() -> Quota { /// Per-IP quota for tests: 120/min burst IP_BURST (mirrors production D-02). pub fn test_per_ip_quota() -> Quota { - Quota::per_minute(NonZeroU32::new(120).unwrap()) - .allow_burst(NonZeroU32::new(IP_BURST).unwrap()) + Quota::per_minute(NonZeroU32::new(120).unwrap()).allow_burst(NonZeroU32::new(IP_BURST).unwrap()) } /// Build a test AppState + per-IP limiter pair using fresh in-memory limiters. /// Returns (state, per_ip_limiter) so callers can assert against the stub. +/// +/// Defaults `trusted_mostro_pubkeys` to an empty set (whitelist disabled — +/// permissive mode). Tests that exercise the whitelist must override this via +/// [`make_app_state_with_whitelist`]. pub fn make_app_state(stub: Arc) -> (AppState, Arc) { + make_app_state_with_whitelist(stub, Arc::new(HashSet::new())) +} + +/// Variant of [`make_app_state`] that injects an explicit trusted-Mostro +/// whitelist. A non-empty set activates the whitelist filter on /api/register. +pub fn make_app_state_with_whitelist( + stub: Arc, + trusted_mostro_pubkeys: Arc>, +) -> (AppState, Arc) { let services: Vec<(Arc, &'static str)> = vec![(stub.clone() as Arc, "stub")]; let dispatcher = Arc::new(PushDispatcher::new(services)); @@ -86,8 +101,7 @@ pub fn make_app_state(stub: Arc) -> (AppState, Arc = Arc::new(RateLimiter::keyed(test_per_pubkey_quota())); - let per_ip_limiter: Arc = - Arc::new(RateLimiter::keyed(test_per_ip_quota())); + let per_ip_limiter: Arc = Arc::new(RateLimiter::keyed(test_per_ip_quota())); let state = AppState { token_store, @@ -95,6 +109,7 @@ pub fn make_app_state(stub: Arc) -> (AppState, Arc TestAppComponents { let stub = Arc::new(StubPushService::new(vec![Platform::Android])); let (state, per_ip_limiter) = make_app_state(stub.clone()); - TestAppComponents { state, per_ip_limiter, stub } + TestAppComponents { + state, + per_ip_limiter, + stub, + } +} + +/// Variant of [`make_test_components`] with the trusted-Mostro whitelist +/// pre-populated with [`TRUSTED_MOSTRO_PUBKEY`]. Use for tests exercising the +/// /api/register whitelist filter. +pub fn make_test_components_with_trusted_whitelist() -> TestAppComponents { + let stub = Arc::new(StubPushService::new(vec![Platform::Android])); + let mut whitelist = HashSet::new(); + whitelist.insert(TRUSTED_MOSTRO_PUBKEY.to_string()); + let (state, per_ip_limiter) = make_app_state_with_whitelist(stub.clone(), Arc::new(whitelist)); + TestAppComponents { + state, + per_ip_limiter, + stub, + } } /// Build an `App` from test components, ready for `test::init_service(...)`. /// Each test calls `test::init_service(build_test_actix_app(c))` to obtain /// the opaque `impl Service` whose type is inferred by the compiler. -pub fn build_test_actix_app(c: TestAppComponents) -> App, - Error = actix_web::Error, - InitError = (), ->> { +pub fn build_test_actix_app( + c: TestAppComponents, +) -> App< + impl actix_web::dev::ServiceFactory< + actix_web::dev::ServiceRequest, + Config = (), + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + InitError = (), + >, +> { App::new() .app_data(web::Data::new(c.state)) .app_data(web::Data::new(c.per_ip_limiter)) @@ -154,20 +193,25 @@ macro_rules! make_test_app { () => {{ let c = $crate::api::test_support::make_test_components(); let stub = c.stub.clone(); - let app = actix_web::test::init_service( - $crate::api::test_support::build_test_actix_app(c) - ).await; + let app = + actix_web::test::init_service($crate::api::test_support::build_test_actix_app(c)).await; (app, stub) }}; } /// Deterministic 64-hex pubkey fixture used across tests. -pub const TEST_PUBKEY: &str = - "1111111111111111111111111111111111111111111111111111111111111111"; +pub const TEST_PUBKEY: &str = "1111111111111111111111111111111111111111111111111111111111111111"; /// Second distinct 64-hex pubkey fixture (canonical "unregistered but format-valid"). -pub const TEST_PUBKEY_2: &str = - "2222222222222222222222222222222222222222222222222222222222222222"; +pub const TEST_PUBKEY_2: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + +/// 64-hex Mostro instance pubkey that whitelist-aware tests treat as trusted. +pub const TRUSTED_MOSTRO_PUBKEY: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +/// 64-hex Mostro instance pubkey that whitelist-aware tests treat as untrusted. +pub const UNTRUSTED_MOSTRO_PUBKEY: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; /// Produce a deterministic 64-hex pubkey from an integer seed. /// Used by per-IP tests that rotate pubkeys to avoid the per-pubkey limiter diff --git a/src/config.rs b/src/config.rs index 551af64..c07a6f2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,7 +18,6 @@ pub struct NostrConfig { pub relays: Vec, pub subscription_id: String, pub event_kinds: Vec, - pub mostro_pubkey: String, } #[derive(Debug, Clone, Deserialize)] @@ -42,9 +41,9 @@ pub struct RateLimitConfig { #[derive(Debug, Clone, Deserialize)] pub struct NotifyRateLimitConfig { - pub per_pubkey_per_min: u32, // NOTIFY_RATE_PER_PUBKEY_PER_MIN, default 30 (D-01) - pub per_ip_per_min: u32, // NOTIFY_RATE_PER_IP_PER_MIN, default 120 (D-02) - pub cleanup_interval_secs: u64, // NOTIFY_RATE_LIMIT_CLEANUP_INTERVAL_SECS, default 60 (D-16) + pub per_pubkey_per_min: u32, // NOTIFY_RATE_PER_PUBKEY_PER_MIN, default 30 (D-01) + pub per_ip_per_min: u32, // NOTIFY_RATE_PER_IP_PER_MIN, default 120 (D-02) + pub cleanup_interval_secs: u64, // NOTIFY_RATE_LIMIT_CLEANUP_INTERVAL_SECS, default 60 (D-16) pub pubkey_limiter_soft_cap: usize, // NOTIFY_PUBKEY_LIMITER_SOFT_CAP, default 100000 (D-17) // NOTIFY_TRUST_PROXY_HEADERS, default false. Set to true ONLY when the // server sits behind a proxy that overwrites Fly-Client-IP / X-Forwarded-For @@ -71,19 +70,11 @@ impl Config { .map(|s| s.trim().to_string()) .collect(); - // Read Mostro instance public key from environment. - // Default to the main Mostro instance pubkey (matches deploy-fly.sh). - let mostro_pubkey = env::var("MOSTRO_PUBKEY") - .unwrap_or_else(|_| { - "82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390".to_string() - }); - Ok(Config { nostr: NostrConfig { relays, subscription_id: "mostro-push-listener".to_string(), event_kinds: vec![1059], - mostro_pubkey, }, push: PushConfig { fcm_enabled: env::var("FCM_ENABLED") @@ -100,8 +91,7 @@ impl Config { .parse()?, }, server: ServerConfig { - host: env::var("SERVER_HOST") - .unwrap_or_else(|_| "0.0.0.0".to_string()), + host: env::var("SERVER_HOST").unwrap_or_else(|_| "0.0.0.0".to_string()), port: env::var("SERVER_PORT") .unwrap_or_else(|_| "8080".to_string()) .parse()?, @@ -114,8 +104,9 @@ impl Config { // Phase 3: Crypto config is optional (encryption disabled) // Phase 4 will require SERVER_PRIVATE_KEY crypto: CryptoConfig { - server_private_key: env::var("SERVER_PRIVATE_KEY") - .unwrap_or_else(|_| "0000000000000000000000000000000000000000000000000000000000000001".to_string()), + server_private_key: env::var("SERVER_PRIVATE_KEY").unwrap_or_else(|_| { + "0000000000000000000000000000000000000000000000000000000000000001".to_string() + }), }, store: StoreConfig { token_ttl_hours: env::var("TOKEN_TTL_HOURS") @@ -187,16 +178,15 @@ mod tests { std::env::set_var("NOTIFY_RATE_PER_PUBKEY_PER_MIN", "0"); std::env::set_var("NOTIFY_RATE_PER_IP_PER_MIN", "120"); std::env::set_var("NOSTR_RELAYS", "wss://relay.example.com"); - std::env::set_var("MOSTRO_PUBKEY", "0".repeat(64)); let result = Config::from_env(); std::env::remove_var("NOTIFY_RATE_PER_PUBKEY_PER_MIN"); std::env::remove_var("NOTIFY_RATE_PER_IP_PER_MIN"); std::env::remove_var("NOSTR_RELAYS"); - std::env::remove_var("MOSTRO_PUBKEY"); - let err = result.expect_err("Config::from_env MUST reject NOTIFY_RATE_PER_PUBKEY_PER_MIN=0"); + let err = + result.expect_err("Config::from_env MUST reject NOTIFY_RATE_PER_PUBKEY_PER_MIN=0"); let msg = err.to_string(); assert!( msg.contains("NOTIFY_RATE_PER_PUBKEY_PER_MIN must be > 0"), @@ -205,29 +195,6 @@ mod tests { ); } - /// Regression: when MOSTRO_PUBKEY is unset, Config::from_env must use the - /// documented main Mostro instance pubkey (matches deploy-fly.sh) rather - /// than a stale fallback. Guards against the duplicate-read bug where the - /// first lookup (with the documented default) was discarded and a second - /// lookup with a different default populated NostrConfig::mostro_pubkey. - #[test] - fn defaults_mostro_pubkey_to_main_instance_when_unset() { - let _guard = ENV_MUTEX.lock().unwrap(); - std::env::remove_var("MOSTRO_PUBKEY"); - std::env::set_var("NOSTR_RELAYS", "wss://relay.example.com"); - - let result = Config::from_env(); - - std::env::remove_var("NOSTR_RELAYS"); - - let cfg = result.expect("Config::from_env must succeed with MOSTRO_PUBKEY unset"); - assert_eq!( - cfg.nostr.mostro_pubkey, - "82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390", - "default MOSTRO_PUBKEY must match deploy-fly.sh and the comment in Config::from_env" - ); - } - /// D-04: NOTIFY_RATE_PER_IP_PER_MIN=0 must be rejected by Config::from_env. #[test] fn rejects_zero_per_ip_rate() { @@ -235,14 +202,12 @@ mod tests { std::env::set_var("NOTIFY_RATE_PER_PUBKEY_PER_MIN", "30"); std::env::set_var("NOTIFY_RATE_PER_IP_PER_MIN", "0"); std::env::set_var("NOSTR_RELAYS", "wss://relay.example.com"); - std::env::set_var("MOSTRO_PUBKEY", "0".repeat(64)); let result = Config::from_env(); std::env::remove_var("NOTIFY_RATE_PER_PUBKEY_PER_MIN"); std::env::remove_var("NOTIFY_RATE_PER_IP_PER_MIN"); std::env::remove_var("NOSTR_RELAYS"); - std::env::remove_var("MOSTRO_PUBKEY"); let err = result.expect_err("Config::from_env MUST reject NOTIFY_RATE_PER_IP_PER_MIN=0"); let msg = err.to_string(); diff --git a/src/main.rs b/src/main.rs index b11a322..439744a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,11 +5,12 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::Semaphore; +mod api; mod config; mod nostr; mod push; -mod api; mod store; +mod trusted_pubkeys; mod utils; // Keep crypto module for future Phase 4 implementation @@ -28,7 +29,10 @@ async fn main() -> std::io::Result<()> { env_logger::init(); dotenv::dotenv().ok(); - info!("Starting Mostro Push Backend v{}...", env!("CARGO_PKG_VERSION")); + info!( + "Starting Mostro Push Backend v{}...", + env!("CARGO_PKG_VERSION") + ); info!("Phase 3: Token registration without encryption"); info!("Encryption will be enabled in Phase 4"); @@ -51,9 +55,9 @@ async fn main() -> std::io::Result<()> { // Start cleanup task store::start_cleanup_task(token_store.clone(), config.store.cleanup_interval_hours); - info!("Token store initialized (TTL: {}h, cleanup interval: {}h)", - config.store.token_ttl_hours, - config.store.cleanup_interval_hours + info!( + "Token store initialized (TTL: {}h, cleanup interval: {}h)", + config.store.token_ttl_hours, config.store.cleanup_interval_hours ); // Single shared reqwest::Client with explicit timeouts. Bounds outbound @@ -72,7 +76,10 @@ async fn main() -> std::io::Result<()> { let mut push_services: Vec<(Arc, &'static str)> = Vec::new(); // Keep UnifiedPush service separate for endpoint management - let unifiedpush_service = Arc::new(UnifiedPushService::new(config.clone(), Arc::clone(&http_client))); + let unifiedpush_service = Arc::new(UnifiedPushService::new( + config.clone(), + Arc::clone(&http_client), + )); // Load existing endpoints from disk if let Err(e) = unifiedpush_service.load_endpoints().await { @@ -99,7 +106,10 @@ async fn main() -> std::io::Result<()> { if config.push.unifiedpush_enabled { info!("Initializing UnifiedPush service"); - push_services.push((Arc::clone(&unifiedpush_service) as Arc, "unifiedpush")); + push_services.push(( + Arc::clone(&unifiedpush_service) as Arc, + "unifiedpush", + )); } let dispatcher = Arc::new(PushDispatcher::new(push_services)); @@ -139,8 +149,7 @@ async fn main() -> std::io::Result<()> { // per-IP cleanup, every distinct client IP the server has ever seen // sticks in the keyed map for the lifetime of the process — a slow // memory leak on long-running instances with diverse client populations. - let cleanup_interval = - Duration::from_secs(config.notify_rate_limit.cleanup_interval_secs); + let cleanup_interval = Duration::from_secs(config.notify_rate_limit.cleanup_interval_secs); let cleanup_soft_cap = config.notify_rate_limit.pubkey_limiter_soft_cap; api::rate_limit::start_keyed_limiter_cleanup_task( per_pubkey_limiter.clone(), @@ -170,12 +179,32 @@ async fn main() -> std::io::Result<()> { dispatcher.clone(), token_store.clone(), notify_log_salt.clone(), - ).expect("Failed to initialize Nostr listener - check MOSTRO_PUBKEY"); + ) + .expect("Failed to initialize Nostr listener"); tokio::spawn(async move { nostr_listener.start().await; }); + // Trusted Mostro instance whitelist embedded at compile time. + // Empty list => permissive mode; populated => /api/register filters + // registrations whose declared mostro_pubkey is not on the list. + let trusted_mostro_pubkeys = Arc::new(trusted_pubkeys::load()); + info!( + "Trusted Mostro pubkey whitelist: {} entr{} ({})", + trusted_mostro_pubkeys.len(), + if trusted_mostro_pubkeys.len() == 1 { + "y" + } else { + "ies" + }, + if trusted_mostro_pubkeys.is_empty() { + "permissive mode" + } else { + "filter active" + } + ); + // Create app state for HTTP handlers let app_state = AppState { token_store: token_store.clone(), @@ -183,6 +212,7 @@ async fn main() -> std::io::Result<()> { semaphore: notify_semaphore.clone(), notify_log_salt: notify_log_salt.clone(), per_pubkey_limiter: per_pubkey_limiter.clone(), + trusted_mostro_pubkeys: trusted_mostro_pubkeys.clone(), }; // Start HTTP API server diff --git a/src/nostr/listener.rs b/src/nostr/listener.rs index eb4e8de..ed7af98 100644 --- a/src/nostr/listener.rs +++ b/src/nostr/listener.rs @@ -1,6 +1,5 @@ -use log::{info, error, warn, debug}; +use log::{debug, error, info, warn}; use nostr_sdk::prelude::*; -use std::str::FromStr; use std::sync::Arc; use tokio::time::{sleep, Duration}; @@ -13,7 +12,6 @@ pub struct NostrListener { config: Config, dispatcher: Arc, token_store: Arc, - mostro_pubkey: String, log_salt: Arc<[u8; 32]>, } @@ -24,20 +22,10 @@ impl NostrListener { token_store: Arc, log_salt: Arc<[u8; 32]>, ) -> Result> { - // Validate the pubkey format - let mostro_pubkey = config.nostr.mostro_pubkey.clone(); - if mostro_pubkey.len() != 64 { - return Err("Invalid MOSTRO_PUBKEY format (expected 64 hex characters)".into()); - } - // Validate it's valid hex by trying to parse it - XOnlyPublicKey::from_str(&mostro_pubkey) - .map_err(|_| "Invalid MOSTRO_PUBKEY (not a valid public key)")?; - Ok(Self { config, dispatcher, token_store, - mostro_pubkey, log_salt, }) } @@ -49,7 +37,10 @@ impl NostrListener { warn!("Nostr connection closed, reconnecting in 5 seconds..."); } Err(e) => { - error!("Error in Nostr listener: {}, reconnecting in 10 seconds...", e); + error!( + "Error in Nostr listener: {}, reconnecting in 10 seconds...", + e + ); sleep(Duration::from_secs(10)).await; } } @@ -80,9 +71,7 @@ impl NostrListener { // A mostro_pubkey author filter would silently drop every dispute notification. // See PROJECT.md anti-requirement OOS-19 / PITFALLS CRIT-1. let since = Timestamp::now() - Duration::from_secs(60); - let filter = Filter::new() - .kinds(vec![Kind::Custom(1059)]) - .since(since); + let filter = Filter::new().kinds(vec![Kind::Custom(1059)]).since(since); // Subscribe to events client.subscribe(vec![filter]).await; diff --git a/src/trusted_pubkeys.rs b/src/trusted_pubkeys.rs new file mode 100644 index 0000000..7a34d2b --- /dev/null +++ b/src/trusted_pubkeys.rs @@ -0,0 +1,53 @@ +use std::collections::HashSet; + +const TRUSTED_PUBKEYS_JSON: &str = include_str!("../config/trusted_mostro_pubkeys.json"); + +/// Load the trusted Mostro instance pubkeys embedded at compile time from +/// `config/trusted_mostro_pubkeys.json`. +/// +/// An empty list disables the whitelist (permissive mode). A non-empty list +/// activates the filter on `/api/register`: clients must declare a +/// `mostro_pubkey` matching one of the entries. +/// +/// Panics at startup if the JSON is malformed or any entry is not 64 hex +/// characters. Failing fast at boot is preferable to silently shipping a +/// degraded whitelist. +pub fn load() -> HashSet { + let raw: Vec = serde_json::from_str(TRUSTED_PUBKEYS_JSON) + .expect("config/trusted_mostro_pubkeys.json must be a valid JSON array of strings"); + for pk in &raw { + assert!( + pk.len() == 64 && hex::decode(pk).is_ok(), + "invalid trusted Mostro pubkey (expected 64 hex chars): {}", + pk + ); + } + raw.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_json_parses_and_validates() { + let set = load(); + assert!( + !set.is_empty(), + "embedded whitelist must not be empty in this build" + ); + for pk in &set { + assert_eq!(pk.len(), 64); + assert!(hex::decode(pk).is_ok()); + } + } + + #[test] + fn default_main_instance_is_present() { + let set = load(); + assert!( + set.contains("82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390"), + "default Mostro main-instance pubkey must be on the trusted list" + ); + } +} From 9b4e85929cbfc05ae771b87b36abb118e4978a62 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 7 May 2026 00:33:33 -0300 Subject: [PATCH 3/5] fix(register): canonicalize mostro_pubkey case and allow empty whitelist build Address PR #23 review feedback: - coderabbit (3 instances of the same finding): the 400-format gate uses hex::decode which is case-insensitive, but the 403 whitelist check used byte-exact HashSet::contains. A client sending an uppercase but otherwise valid pubkey would pass the 400 gate and falsely hit 403. Lowercase entries when populating the set in trusted_pubkeys::load and lowercase the incoming mostro_pubkey at the HTTP boundary in register_token before contains. - chatgpt-codex: the test embedded_json_parses_and_validates asserted that the embedded set was non-empty, which broke `cargo test` for an operator who chose the documented permissive configuration (`[]` in config/trusted_mostro_pubkeys.json). Drop the cardinality assertion, drop the redundant default_main_instance_is_present test (it pinned a specific pubkey that should not gate the build), and keep only a format/canonicalization check that succeeds on any valid input including the empty array. Adds a regression test (register_with_uppercase_trusted_mostro_pubkey_succeeds) that submits the uppercase form of a trusted entry and asserts 200. --- src/api/routes.rs | 39 ++++++++++++++++++++++++++++++++++++++- src/trusted_pubkeys.rs | 32 +++++++++++++++++--------------- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/api/routes.rs b/src/api/routes.rs index f4a8920..cda20e8 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -178,7 +178,15 @@ async fn register_token( platform: None, }); } - if !state.trusted_mostro_pubkeys.contains(mostro_pk) { + // Canonicalize at the HTTP boundary: hex::decode accepts mixed + // case but HashSet::contains is byte-exact, so an uppercase but + // otherwise valid pubkey would falsely 403. trusted_pubkeys::load + // also lowercases on the whitelist side; normalize here so the + // invariant holds in both directions. + if !state + .trusted_mostro_pubkeys + .contains(&mostro_pk.to_ascii_lowercase()) + { warn!("Register denied: untrusted Mostro instance"); return HttpResponse::Forbidden().json(RegisterResponse { success: false, @@ -512,6 +520,35 @@ mod tests { ); } + /// PR #23 review regression: an uppercase pubkey that lowercases to a + /// trusted entry must succeed. `hex::decode` accepts mixed case at the + /// 400 gate, and the handler must canonicalize to lowercase before the + /// 403 whitelist check so it agrees with `trusted_pubkeys::load`, which + /// stores entries in lowercase. + #[actix_web::test] + async fn register_with_uppercase_trusted_mostro_pubkey_succeeds() { + let c = make_test_components_with_trusted_whitelist(); + let app = atest::init_service(build_test_actix_app(c)).await; + + let uppercase = TRUSTED_MOSTRO_PUBKEY.to_ascii_uppercase(); + assert_ne!( + uppercase, TRUSTED_MOSTRO_PUBKEY, + "fixture must contain at least one hex letter for this test to be meaningful" + ); + + let req = atest::TestRequest::post() + .uri("/api/register") + .set_json(serde_json::json!({ + "trade_pubkey": TEST_PUBKEY, + "token": "test_fcm_token", + "platform": "android", + "mostro_pubkey": uppercase + })) + .to_request(); + let resp = atest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + } + /// Whitelist active, declared mostro_pubkey NOT in the list -> 403. #[actix_web::test] async fn register_with_untrusted_mostro_pubkey_returns_403() { diff --git a/src/trusted_pubkeys.rs b/src/trusted_pubkeys.rs index 7a34d2b..0d6203c 100644 --- a/src/trusted_pubkeys.rs +++ b/src/trusted_pubkeys.rs @@ -22,32 +22,34 @@ pub fn load() -> HashSet { pk ); } - raw.into_iter().collect() + // Normalize to lowercase so the byte-exact `HashSet::contains` lookup in + // `register_token` matches both `"82FA..."` and `"82fa..."` once the + // handler also lowercases the incoming `mostro_pubkey`. `hex::decode` + // already accepts mixed case at both boundaries, so without this + // normalization a syntactically valid uppercase key would pass the 400 + // gate and falsely hit 403. + raw.into_iter().map(|pk| pk.to_ascii_lowercase()).collect() } #[cfg(test)] mod tests { use super::*; + /// `load()` must succeed and yield only valid 64-hex entries. An empty + /// array is the documented permissive-mode configuration, so cardinality + /// is intentionally not asserted — operators who edit the embedded JSON + /// must not be forced to keep at least one entry just to satisfy tests. #[test] - fn embedded_json_parses_and_validates() { + fn embedded_json_parses_with_valid_entries() { let set = load(); - assert!( - !set.is_empty(), - "embedded whitelist must not be empty in this build" - ); for pk in &set { assert_eq!(pk.len(), 64); assert!(hex::decode(pk).is_ok()); + assert!( + pk.chars().all(|c| !c.is_ascii_uppercase()), + "load() must canonicalize entries to lowercase: {}", + pk + ); } } - - #[test] - fn default_main_instance_is_present() { - let set = load(); - assert!( - set.contains("82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390"), - "default Mostro main-instance pubkey must be on the trusted list" - ); - } } From a159dd8a7fa5d998b8cd8286afe4cba20f5335ed Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 7 May 2026 00:42:52 -0300 Subject: [PATCH 4/5] fix(docker): copy config/ into build context so include_str! resolves PR #23 review (chatgpt-codex, P1): the builder stage copies only Cargo.toml, Cargo.lock and src/ before `cargo build --release`. With the trusted-instances whitelist now embedded via `include_str!("../config/trusted_mostro_pubkeys.json")`, that path is absent inside the image and the build fails. Add a COPY for `config/` ahead of the build step so Fly/Docker deployments compile against the same JSON the developer commits. --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 0c19f3e..1549245 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ FROM rust:1.83 as builder WORKDIR /usr/src/app COPY Cargo.toml Cargo.lock ./ COPY src ./src +COPY config ./config RUN cargo build --release From 39d7aad57921ca803b016fdf9f00a039b6ff5cc0 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 7 May 2026 01:04:28 -0300 Subject: [PATCH 5/5] feat(register): add TRUSTED_WHITELIST_ENABLED runtime flag for staged rollout --- .env.example | 9 +- CLAUDE.md | 2 + docs/api.md | 36 +++++-- docs/configuration.md | 35 +++++-- src/api/routes.rs | 208 ++++++++++++++++++++++++++++++++++++---- src/api/test_support.rs | 40 ++++++-- src/config.rs | 15 +++ src/main.rs | 21 ++-- 8 files changed, 316 insertions(+), 50 deletions(-) diff --git a/.env.example b/.env.example index dd9b51a..e802d84 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,17 @@ # Nostr Configuration NOSTR_RELAYS=wss://relay.mostro.network -# The list of trusted Mostro instance pubkeys is now compiled into the binary +# The list of trusted Mostro instance pubkeys is compiled into the binary # from config/trusted_mostro_pubkeys.json. To add or remove instances, edit # that file and rebuild. There is no MOSTRO_PUBKEY environment variable # anymore. +# +# Runtime feature flag for the trusted-Mostro-instance whitelist on +# /api/register. When false (the default), the embedded list is ignored and +# the `mostro_pubkey` field on registration is permissive. Set to true ONLY +# after the mobile client is rolled out with support for sending the field; +# otherwise older clients that don't send it will be rejected with 403. +TRUSTED_WHITELIST_ENABLED=false # Server Keypair (REQUIRED) # Generate with: openssl rand -hex 32 diff --git a/CLAUDE.md b/CLAUDE.md index cdf3e0d..748a5e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,8 @@ These are the privacy and compatibility invariants of the project. Reintroducing 3. **Backwards compatibility of the existing endpoints.** `/api/health`, `/api/info`, `/api/status`, `/api/register`, `/api/unregister` response bodies are byte-identical to fixtures captured before v1.1. Field order on `RegisterResponse` is `success, message, platform`. The `mostro_pubkey` field added to `RegisterTokenRequest` is request-only and does not change response shapes. + **Exception (off by default):** when `TRUSTED_WHITELIST_ENABLED=true` AND the embedded whitelist is non-empty, `/api/register` MAY return a new `403 Forbidden` with one of two distinct bodies — `{"success":false,"message":"Mostro instance pubkey required"}` (missing field) or `{"success":false,"message":"Mostro instance not trusted"}` (untrusted value). The flag defaults to `false` precisely so the byte-identical fixture set continues to hold for clients that pre-date the feature; only flip it after the mobile rollout. + 4. **Token store is in-memory only.** No persistence to disk for `trade_pubkey -> device_token`. UnifiedPush endpoints are the only on-disk state (atomic JSON write to `data/unifiedpush_endpoints.json`). 5. **Logs never carry raw pubkeys.** Every log site that touches a `trade_pubkey` goes through `crate::utils::log_pubkey::log_pubkey(salt, pubkey)`. The salt is a 32-byte random value generated once per process and never persisted. diff --git a/docs/api.md b/docs/api.md index a354b79..91c2231 100644 --- a/docs/api.md +++ b/docs/api.md @@ -109,6 +109,22 @@ Possible validation errors: Trusted-instance filter — `403 Forbidden`: +The filter is gated by `TRUSTED_WHITELIST_ENABLED` (default `false`) and +only fires when the runtime flag is `true` AND the embedded whitelist is +non-empty (see [configuration.md](./configuration.md)). When it does +fire, the response body distinguishes two cases so clients can react +without parsing logs: + +```json +{ + "success": false, + "message": "Mostro instance pubkey required" +} +``` + +Returned when the `mostro_pubkey` field is absent. Typical for clients +that pre-date the feature. + ```json { "success": false, @@ -116,12 +132,20 @@ Trusted-instance filter — `403 Forbidden`: } ``` -Returned when the trusted Mostro instance whitelist is non-empty AND the -client either omitted `mostro_pubkey` or sent a value that is not on the -whitelist. The whitelist is compiled into the binary from -`config/trusted_mostro_pubkeys.json`; see [configuration.md](./configuration.md). -This filter is honour-system: there is no cryptographic proof binding the -device to the declared instance. +Returned when the field is present, hex-valid, but the value is not on +the whitelist. + +The whitelist is compiled into the binary from +`config/trusted_mostro_pubkeys.json`. The filter is honour-system: there +is no cryptographic proof binding the device to the declared instance. + +**Mobile client compatibility.** The `mostro_pubkey` field is supported +by mobile client `vX.Y.Z` and later (TODO: pin the released version once +the mobile-side change merges). Clients older than that release will +receive `403 "Mostro instance pubkey required"` whenever +`TRUSTED_WHITELIST_ENABLED=true`. Operators should keep the flag at +`false` during the rollout window and flip it on after the mobile +release is in users' hands. ## POST /api/unregister diff --git a/docs/configuration.md b/docs/configuration.md index a4834a0..57ed09a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,17 +25,40 @@ events by `authors` (privacy invariant; see [architecture.md](./architecture.md) The set of Mostro instance pubkeys allowed to register devices is compiled into the binary from `config/trusted_mostro_pubkeys.json` at build time. +Activation is gated by a runtime feature flag, so the JSON can ship +populated while the filter stays inert until the mobile rollout is ready. -- The file must contain a JSON array of 64-character hex pubkeys. -- An empty array disables the whitelist (permissive mode); any client may - register without declaring a `mostro_pubkey`. -- A non-empty array activates the filter on `/api/register`: clients must - send a `mostro_pubkey` field whose value matches one of the entries, - otherwise the request is rejected with `403 Forbidden`. +| Variable | Default | Description | +|------------------------------|---------|----------------------------------------------------------------------------------------------------------------------------| +| `TRUSTED_WHITELIST_ENABLED` | `false` | When `true`, `/api/register` rejects requests whose declared `mostro_pubkey` is missing or not on the embedded whitelist. | + +Activation rule: the filter on `/api/register` only fires when **both** +`TRUSTED_WHITELIST_ENABLED=true` **and** the embedded whitelist is +non-empty. Either side off => permissive mode and the `mostro_pubkey` +field is ignored. + +About the embedded JSON: + +- The file must contain a JSON array of 64-character hex pubkeys + (lowercase preferred; `load()` canonicalizes to lowercase regardless). +- An empty array keeps the filter permissive even when the flag is on. - The file is parsed at startup; malformed JSON or any entry that is not 64 hex characters causes the process to panic immediately (fail-fast). +- Editing the list requires a rebuild because the JSON is embedded at + compile time via `include_str!`. Toggling the flag does not. + +When the filter rejects, the response is `403 Forbidden` with one of two +distinct bodies (see [api.md](./api.md) for the wire details): + +- `{"success":false,"message":"Mostro instance pubkey required"}` when the + field is absent — typical for an old mobile client that pre-dates the + feature. +- `{"success":false,"message":"Mostro instance not trusted"}` when the + field is present but its value is not on the whitelist. To change the list, edit `config/trusted_mostro_pubkeys.json` and rebuild. +To turn the filter on/off without rebuilding, flip +`TRUSTED_WHITELIST_ENABLED`. ## HTTP server diff --git a/src/api/routes.rs b/src/api/routes.rs index cda20e8..3736271 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -55,10 +55,15 @@ pub struct AppState { pub semaphore: Arc, pub notify_log_salt: Arc<[u8; 32]>, pub per_pubkey_limiter: Arc, - /// Whitelist of trusted Mostro instance pubkeys (hex, 64 chars). - /// Empty set disables the whitelist (permissive mode); a populated set - /// activates the filter on `/api/register`. + /// Whitelist of trusted Mostro instance pubkeys (hex, 64 chars), kept + /// in lowercase by `trusted_pubkeys::load`. pub trusted_mostro_pubkeys: Arc>, + /// Runtime feature flag from `TRUSTED_WHITELIST_ENABLED`. The filter on + /// `/api/register` only activates when this is `true` AND + /// `trusted_mostro_pubkeys` is non-empty. With the flag off (the + /// default), `mostro_pubkey` is ignored even if the embedded JSON has + /// entries, which keeps rollout staged with the mobile client. + pub trusted_whitelist_enabled: bool, } pub fn configure(cfg: &mut web::ServiceConfig) { @@ -152,19 +157,31 @@ async fn register_token( } }; - // Trusted Mostro instance whitelist. Empty set => permissive mode - // (mostro_pubkey ignored). Populated set => the client MUST declare a - // mostro_pubkey present in the list, otherwise registration is denied. - // Honor-system filter only: there is no cryptographic proof that the - // device actually uses the declared instance. This will be hardened in a - // future phase when registration carries a signature from the daemon. - if !state.trusted_mostro_pubkeys.is_empty() { + // Trusted Mostro instance whitelist filter. + // + // Activation requires BOTH the runtime feature flag + // (`TRUSTED_WHITELIST_ENABLED`, default false) AND a non-empty embedded + // whitelist. Either side off => permissive mode and `mostro_pubkey` is + // ignored. The flag is what allows the JSON to be shipped populated + // while keeping the new 403 path off until the mobile client supports + // sending the field. + // + // Honor-system filter: there is no cryptographic proof that the device + // actually uses the declared instance. This will be hardened in a + // future phase when registration carries a daemon-issued signature. + // + // 403 messages distinguish two cases so the mobile client can tell + // "you didn't send the field" from "the value you sent isn't on the + // list" without parsing logs: + // - missing field -> "Mostro instance pubkey required" + // - untrusted value -> "Mostro instance not trusted" + if state.trusted_whitelist_enabled && !state.trusted_mostro_pubkeys.is_empty() { match req.mostro_pubkey.as_deref() { None => { warn!("Register denied: mostro_pubkey missing while whitelist active"); return HttpResponse::Forbidden().json(RegisterResponse { success: false, - message: "Mostro instance not trusted".to_string(), + message: "Mostro instance pubkey required".to_string(), platform: None, }); } @@ -258,9 +275,12 @@ async fn unregister_token( mod tests { use super::*; use crate::api::test_support::{ - build_test_actix_app, make_test_components, make_test_components_with_trusted_whitelist, - TEST_PUBKEY, TEST_PUBKEY_2, TRUSTED_MOSTRO_PUBKEY, UNTRUSTED_MOSTRO_PUBKEY, + build_test_actix_app, make_app_state_with_whitelist, make_test_components, + make_test_components_with_trusted_whitelist, make_test_components_with_whitelist_disabled, + StubPushService, TestAppComponents, TEST_PUBKEY, TEST_PUBKEY_2, TRUSTED_MOSTRO_PUBKEY, + UNTRUSTED_MOSTRO_PUBKEY, }; + use crate::store::Platform; use actix_web::{http::StatusCode, test as atest}; /// VERIFY-02 / D-24 #6: /api/register success body is BYTE-IDENTICAL to @@ -574,9 +594,13 @@ mod tests { ); } - /// Whitelist active, mostro_pubkey field omitted -> 403 (same body as untrusted). + /// Whitelist active, mostro_pubkey field omitted -> 403 with the + /// distinct "required" message (vs. "not trusted" for unknown values). + /// Splitting the messages lets the mobile client tell apart "you didn't + /// send the field" from "the value you sent isn't whitelisted" without + /// parsing logs. #[actix_web::test] - async fn register_without_mostro_pubkey_when_whitelist_active_returns_403() { + async fn register_without_mostro_pubkey_when_flag_enabled_returns_403_with_required_message() { let c = make_test_components_with_trusted_whitelist(); let app = atest::init_service(build_test_actix_app(c)).await; @@ -594,7 +618,7 @@ mod tests { let body_str = std::str::from_utf8(&body).unwrap(); assert_eq!( body_str, - r#"{"success":false,"message":"Mostro instance not trusted"}"# + r#"{"success":false,"message":"Mostro instance pubkey required"}"# ); } @@ -643,4 +667,156 @@ mod tests { let resp = atest::call_service(&app, req).await; assert_eq!(resp.status(), StatusCode::OK); } + + /// Rollout-safety regression: even with a populated whitelist, when the + /// runtime feature flag (`TRUSTED_WHITELIST_ENABLED`) is OFF the filter + /// must NOT reject a client that sends an untrusted `mostro_pubkey`. The + /// field is ignored end-to-end. This is what allows the binary to ship + /// with the JSON populated before the mobile client knows how to send + /// the field. + #[actix_web::test] + async fn register_with_trusted_pubkey_but_flag_disabled_ignores_field() { + let c = make_test_components_with_whitelist_disabled(); + let app = atest::init_service(build_test_actix_app(c)).await; + + let req = atest::TestRequest::post() + .uri("/api/register") + .set_json(serde_json::json!({ + "trade_pubkey": TEST_PUBKEY, + "token": "test_fcm_token", + "platform": "android", + "mostro_pubkey": UNTRUSTED_MOSTRO_PUBKEY + })) + .to_request(); + let resp = atest::call_service(&app, req).await; + assert_eq!( + resp.status(), + StatusCode::OK, + "filter must be inert when TRUSTED_WHITELIST_ENABLED is false" + ); + } + + /// Same as above but with the field absent. Permissive when the flag is + /// off, even if the embedded list has entries. + #[actix_web::test] + async fn register_without_mostro_pubkey_when_flag_disabled_succeeds() { + let c = make_test_components_with_whitelist_disabled(); + let app = atest::init_service(build_test_actix_app(c)).await; + + let req = atest::TestRequest::post() + .uri("/api/register") + .set_json(serde_json::json!({ + "trade_pubkey": TEST_PUBKEY_2, + "token": "test_fcm_token", + "platform": "android" + })) + .to_request(); + let resp = atest::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + } + + /// Activation-matrix coverage. The filter must reject ONLY in the + /// (flag=true, list=non-empty) cell and only when the field is missing + /// or untrusted; every other combination must succeed. Encodes the + /// activation rule directly: filter ⇔ flag ∧ list ∧ (missing ∨ untrusted). + #[actix_web::test] + async fn whitelist_activation_matrix() { + struct Case { + label: &'static str, + flag_enabled: bool, + whitelist_populated: bool, + field: Option<&'static str>, // None => omit, Some => send + expected: StatusCode, + } + + // Distinct trade pubkeys per case so the in-memory store doesn't + // dedupe registrations and confuse a later case. + let cases = [ + Case { + label: "flag=off, list=empty, no field", + flag_enabled: false, + whitelist_populated: false, + field: None, + expected: StatusCode::OK, + }, + Case { + label: "flag=off, list=non-empty, no field", + flag_enabled: false, + whitelist_populated: true, + field: None, + expected: StatusCode::OK, + }, + Case { + label: "flag=on, list=empty, no field", + flag_enabled: true, + whitelist_populated: false, + field: None, + expected: StatusCode::OK, + }, + Case { + label: "flag=on, list=non-empty, trusted field", + flag_enabled: true, + whitelist_populated: true, + field: Some(TRUSTED_MOSTRO_PUBKEY), + expected: StatusCode::OK, + }, + Case { + label: "flag=on, list=non-empty, untrusted field", + flag_enabled: true, + whitelist_populated: true, + field: Some(UNTRUSTED_MOSTRO_PUBKEY), + expected: StatusCode::FORBIDDEN, + }, + Case { + label: "flag=on, list=non-empty, missing field", + flag_enabled: true, + whitelist_populated: true, + field: None, + expected: StatusCode::FORBIDDEN, + }, + ]; + + for (i, case) in cases.iter().enumerate() { + let stub = std::sync::Arc::new(StubPushService::new(vec![Platform::Android])); + let mut whitelist = std::collections::HashSet::new(); + if case.whitelist_populated { + whitelist.insert(TRUSTED_MOSTRO_PUBKEY.to_string()); + } + let (state, per_ip_limiter) = make_app_state_with_whitelist( + stub.clone(), + std::sync::Arc::new(whitelist), + case.flag_enabled, + ); + let components = TestAppComponents { + state, + per_ip_limiter, + stub, + }; + let app = atest::init_service(build_test_actix_app(components)).await; + + let trade_pk = format!("{:0>64x}", i + 100); + let mut body = serde_json::json!({ + "trade_pubkey": trade_pk, + "token": "test_fcm_token", + "platform": "android", + }); + if let Some(pk) = case.field { + body["mostro_pubkey"] = serde_json::Value::String(pk.to_string()); + } + let req = atest::TestRequest::post() + .uri("/api/register") + .set_json(&body) + .to_request(); + let resp = atest::call_service(&app, req).await; + assert_eq!( + resp.status(), + case.expected, + "matrix case {} ({}) expected {} got {}", + i, + case.label, + case.expected, + resp.status() + ); + } + } } diff --git a/src/api/test_support.rs b/src/api/test_support.rs index 62caeb8..1de3fe4 100644 --- a/src/api/test_support.rs +++ b/src/api/test_support.rs @@ -75,18 +75,21 @@ pub fn test_per_ip_quota() -> Quota { /// Build a test AppState + per-IP limiter pair using fresh in-memory limiters. /// Returns (state, per_ip_limiter) so callers can assert against the stub. /// -/// Defaults `trusted_mostro_pubkeys` to an empty set (whitelist disabled — -/// permissive mode). Tests that exercise the whitelist must override this via -/// [`make_app_state_with_whitelist`]. +/// Defaults `trusted_mostro_pubkeys` to an empty set and +/// `trusted_whitelist_enabled` to `false` (permissive mode). Tests exercising +/// the whitelist must override this via [`make_app_state_with_whitelist`]. pub fn make_app_state(stub: Arc) -> (AppState, Arc) { - make_app_state_with_whitelist(stub, Arc::new(HashSet::new())) + make_app_state_with_whitelist(stub, Arc::new(HashSet::new()), false) } /// Variant of [`make_app_state`] that injects an explicit trusted-Mostro -/// whitelist. A non-empty set activates the whitelist filter on /api/register. +/// whitelist and feature-flag value. The filter on /api/register only fires +/// when `trusted_whitelist_enabled` is `true` AND `trusted_mostro_pubkeys` +/// is non-empty. pub fn make_app_state_with_whitelist( stub: Arc, trusted_mostro_pubkeys: Arc>, + trusted_whitelist_enabled: bool, ) -> (AppState, Arc) { let services: Vec<(Arc, &'static str)> = vec![(stub.clone() as Arc, "stub")]; @@ -110,6 +113,7 @@ pub fn make_app_state_with_whitelist( notify_log_salt, per_pubkey_limiter, trusted_mostro_pubkeys, + trusted_whitelist_enabled, }; (state, per_ip_limiter) @@ -144,13 +148,33 @@ pub fn make_test_components() -> TestAppComponents { } /// Variant of [`make_test_components`] with the trusted-Mostro whitelist -/// pre-populated with [`TRUSTED_MOSTRO_PUBKEY`]. Use for tests exercising the -/// /api/register whitelist filter. +/// pre-populated with [`TRUSTED_MOSTRO_PUBKEY`] AND the runtime feature flag +/// turned on. Use for tests exercising the active /api/register whitelist +/// filter. For tests that exercise the flag-disabled path with a non-empty +/// whitelist, use [`make_test_components_with_whitelist_disabled`]. pub fn make_test_components_with_trusted_whitelist() -> TestAppComponents { let stub = Arc::new(StubPushService::new(vec![Platform::Android])); let mut whitelist = HashSet::new(); whitelist.insert(TRUSTED_MOSTRO_PUBKEY.to_string()); - let (state, per_ip_limiter) = make_app_state_with_whitelist(stub.clone(), Arc::new(whitelist)); + let (state, per_ip_limiter) = + make_app_state_with_whitelist(stub.clone(), Arc::new(whitelist), true); + TestAppComponents { + state, + per_ip_limiter, + stub, + } +} + +/// Variant with the trusted-Mostro whitelist populated but the runtime +/// feature flag turned OFF. Used to assert that the filter is genuinely +/// inert when the flag is false even when the embedded JSON ships with +/// entries — i.e. that the rollout safety property holds. +pub fn make_test_components_with_whitelist_disabled() -> TestAppComponents { + let stub = Arc::new(StubPushService::new(vec![Platform::Android])); + let mut whitelist = HashSet::new(); + whitelist.insert(TRUSTED_MOSTRO_PUBKEY.to_string()); + let (state, per_ip_limiter) = + make_app_state_with_whitelist(stub.clone(), Arc::new(whitelist), false); TestAppComponents { state, per_ip_limiter, diff --git a/src/config.rs b/src/config.rs index c07a6f2..7a83341 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,14 @@ pub struct Config { pub crypto: CryptoConfig, pub store: StoreConfig, pub notify_rate_limit: NotifyRateLimitConfig, + /// Runtime feature flag for the trusted-Mostro-instance whitelist on + /// `/api/register`. Sourced from `TRUSTED_WHITELIST_ENABLED`, default + /// `false`. The filter only activates when this is `true` AND the + /// embedded whitelist (`config/trusted_mostro_pubkeys.json`) is + /// non-empty; otherwise the `mostro_pubkey` field is ignored. This + /// indirection lets the binary ship with the JSON populated while + /// keeping the new 403 path off until the mobile client is rolled out. + pub trusted_whitelist_enabled: bool, } #[derive(Debug, Clone, Deserialize)] @@ -157,6 +165,13 @@ impl Config { .unwrap_or_else(|_| "false".to_string()) .parse()?, }, + // Default false: ship the binary with the embedded whitelist + // populated but the 403 path off, so the mobile client can be + // rolled out before the filter starts rejecting clients that + // still don't send `mostro_pubkey`. + trusted_whitelist_enabled: env::var("TRUSTED_WHITELIST_ENABLED") + .unwrap_or_else(|_| "false".to_string()) + .parse()?, }) } } diff --git a/src/main.rs b/src/main.rs index 439744a..28bd8b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -187,22 +187,16 @@ async fn main() -> std::io::Result<()> { }); // Trusted Mostro instance whitelist embedded at compile time. - // Empty list => permissive mode; populated => /api/register filters - // registrations whose declared mostro_pubkey is not on the list. + // The /api/register filter activates only when BOTH the runtime feature + // flag (`TRUSTED_WHITELIST_ENABLED`) is true AND the embedded list is + // non-empty; otherwise `mostro_pubkey` is ignored. Logging both the + // count and the flag at boot lets operators tell apart "shipped without + // entries" from "flag forgotten" without grepping config. let trusted_mostro_pubkeys = Arc::new(trusted_pubkeys::load()); info!( - "Trusted Mostro pubkey whitelist: {} entr{} ({})", + "Loaded trusted-Mostro whitelist with {} pubkeys (enabled: {})", trusted_mostro_pubkeys.len(), - if trusted_mostro_pubkeys.len() == 1 { - "y" - } else { - "ies" - }, - if trusted_mostro_pubkeys.is_empty() { - "permissive mode" - } else { - "filter active" - } + config.trusted_whitelist_enabled ); // Create app state for HTTP handlers @@ -213,6 +207,7 @@ async fn main() -> std::io::Result<()> { notify_log_salt: notify_log_salt.clone(), per_pubkey_limiter: per_pubkey_limiter.clone(), trusted_mostro_pubkeys: trusted_mostro_pubkeys.clone(), + trusted_whitelist_enabled: config.trusted_whitelist_enabled, }; // Start HTTP API server