Skip to content

Commit 5e48ea2

Browse files
author
EchoBT
committed
feat(security): implement authenticated P2P communication with challenge containers
- Add ContainerAuthSession for tracking authenticated sessions - Platform signs auth request before P2P communication - All P2P requests include X-Auth-Token header - Automatic re-authentication on token expiry - Session caching to avoid repeated auth calls - Prevents challenge container impersonation
1 parent 60b115c commit 5e48ea2

File tree

3 files changed

+208
-5
lines changed

3 files changed

+208
-5
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bins/validator-node/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,4 @@ uuid = { workspace = true }
4646
sha2 = { workspace = true }
4747
chrono = { workspace = true }
4848
sp-core = { workspace = true }
49+
rand = { workspace = true }

bins/validator-node/src/main.rs

Lines changed: 206 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,43 @@ use platform_subnet_manager::BanList;
2929
use std::collections::HashMap;
3030
use std::path::PathBuf;
3131
use std::sync::Arc;
32+
use std::time::{SystemTime, UNIX_EPOCH};
3233
use tokio::sync::mpsc;
3334
use tracing::{debug, error, info, warn};
3435

36+
// ==================== Container Authentication ====================
37+
38+
/// Session with a challenge container
39+
#[derive(Debug, Clone)]
40+
#[allow(dead_code)]
41+
struct ContainerAuthSession {
42+
/// The authentication token
43+
token: String,
44+
/// When the session expires
45+
expires_at: u64,
46+
/// Challenge container name (for logging)
47+
container_name: String,
48+
}
49+
50+
/// Request body for authenticating with a challenge container
51+
#[derive(Debug, Clone, serde::Serialize)]
52+
struct ContainerAuthRequest {
53+
hotkey: String,
54+
challenge_id: String,
55+
timestamp: u64,
56+
nonce: String,
57+
signature: String,
58+
}
59+
60+
/// Response from challenge container authentication
61+
#[derive(Debug, Clone, serde::Deserialize)]
62+
struct ContainerAuthResponse {
63+
success: bool,
64+
session_token: Option<String>,
65+
expires_at: Option<u64>,
66+
error: Option<String>,
67+
}
68+
3569
#[derive(Parser, Debug)]
3670
#[command(name = "validator-node")]
3771
#[command(about = "Mini-chain validator node")]
@@ -339,6 +373,10 @@ async fn main() -> Result<()> {
339373
let challenge_endpoints: Arc<RwLock<std::collections::HashMap<String, String>>> =
340374
Arc::new(RwLock::new(std::collections::HashMap::new()));
341375

376+
// Store authenticated sessions with challenge containers
377+
let container_auth_sessions: Arc<RwLock<HashMap<String, ContainerAuthSession>>> =
378+
Arc::new(RwLock::new(HashMap::new()));
379+
342380
// Start RPC server (if enabled)
343381
// Challenge-specific logic is handled by Docker containers
344382
// The validator only proxies requests to challenges via HTTP
@@ -1194,6 +1232,9 @@ async fn main() -> Result<()> {
11941232
info!("Validator node running. Press Ctrl+C to stop.");
11951233

11961234
let chain_state_clone = chain_state.clone();
1235+
// Clone keypair and auth sessions for P2P message handling
1236+
let keypair_for_p2p = Some(keypair.clone());
1237+
let auth_sessions_for_p2p = Some(container_auth_sessions.clone());
11971238
// Get challenge_routes Arc for auto-registration when receiving via P2P
11981239
let challenge_routes_for_p2p = rpc_handler.as_ref().map(|h| h.challenge_routes.clone());
11991240
// Get distributed_db for P2P message handling
@@ -1568,7 +1609,7 @@ async fn main() -> Result<()> {
15681609

15691610
if has_sufficient_stake {
15701611
// Forward all messages to consensus handler
1571-
handle_message(&consensus, signed, &chain_state_clone, challenge_orchestrator.as_ref(), challenge_routes_for_p2p.as_ref(), db_for_p2p.as_ref()).await;
1612+
handle_message(&consensus, signed, &chain_state_clone, challenge_orchestrator.as_ref(), challenge_routes_for_p2p.as_ref(), db_for_p2p.as_ref(), keypair_for_p2p.as_ref(), auth_sessions_for_p2p.as_ref()).await;
15721613
} else {
15731614
// Allow Sudo to bypass stake check for bootstrapping and upgrades
15741615
let is_sudo = {
@@ -1578,7 +1619,7 @@ async fn main() -> Result<()> {
15781619

15791620
if is_sudo {
15801621
info!("Bypassing stake check for Sudo message from {}", &signer_hex[..16]);
1581-
handle_message(&consensus, signed, &chain_state_clone, challenge_orchestrator.as_ref(), challenge_routes_for_p2p.as_ref(), db_for_p2p.as_ref()).await;
1622+
handle_message(&consensus, signed, &chain_state_clone, challenge_orchestrator.as_ref(), challenge_routes_for_p2p.as_ref(), db_for_p2p.as_ref(), keypair_for_p2p.as_ref(), auth_sessions_for_p2p.as_ref()).await;
15821623
} else {
15831624
warn!(
15841625
"Rejected message from {} - insufficient stake (min {} TAO required)",
@@ -1878,6 +1919,8 @@ async fn handle_message(
18781919
&Arc<RwLock<HashMap<String, Vec<platform_challenge_sdk::ChallengeRoute>>>>,
18791920
>,
18801921
distributed_db: Option<&Arc<distributed_db::DistributedDB>>,
1922+
keypair: Option<&Keypair>,
1923+
container_auth_sessions: Option<&Arc<RwLock<HashMap<String, ContainerAuthSession>>>>,
18811924
) {
18821925
let signer = msg.signer().clone();
18831926

@@ -2073,14 +2116,36 @@ async fn handle_message(
20732116
);
20742117

20752118
// Forward challenge message to the appropriate container via HTTP
2119+
// Need keypair for authentication
2120+
if keypair.is_none() || container_auth_sessions.is_none() {
2121+
debug!("Skipping challenge message forward - no auth context available");
2122+
return;
2123+
}
2124+
20762125
let challenge_id = challenge_msg.challenge_id.clone();
20772126
let container_name = challenge_id.to_lowercase().replace([' ', '_'], "-");
20782127
let from_hotkey = signer.to_hex();
20792128
let msg_payload = challenge_msg.payload.clone();
2129+
let kp = keypair.unwrap().clone();
2130+
let sessions = container_auth_sessions.unwrap().clone();
20802131

20812132
tokio::spawn(async move {
20822133
let client = reqwest::Client::new();
2083-
let p2p_endpoint = format!("http://challenge-{}:8080/p2p/message", container_name);
2134+
let base_url = format!("http://challenge-{}:8080", container_name);
2135+
let p2p_endpoint = format!("{}/p2p/message", base_url);
2136+
2137+
// Get or create authentication token
2138+
let auth_token =
2139+
match get_container_auth(&sessions, &base_url, &challenge_id, &kp).await {
2140+
Ok(token) => token,
2141+
Err(e) => {
2142+
warn!(
2143+
"Failed to authenticate with container {}: {}",
2144+
container_name, e
2145+
);
2146+
return;
2147+
}
2148+
};
20842149

20852150
// Convert ChallengeMessageType to ChallengeP2PMessage for the container
20862151
let p2p_message = match challenge_msg.message_type {
@@ -2126,13 +2191,24 @@ async fn handle_message(
21262191
"message": message
21272192
});
21282193

2129-
match client.post(&p2p_endpoint).json(&req_body).send().await {
2194+
match client
2195+
.post(&p2p_endpoint)
2196+
.header("X-Auth-Token", &auth_token)
2197+
.json(&req_body)
2198+
.send()
2199+
.await
2200+
{
21302201
Ok(resp) if resp.status().is_success() => {
21312202
debug!(
2132-
"Forwarded challenge message to container {}",
2203+
"Forwarded challenge message to container {} (authenticated)",
21332204
container_name
21342205
);
21352206
}
2207+
Ok(resp) if resp.status() == reqwest::StatusCode::UNAUTHORIZED => {
2208+
// Token expired, remove from cache
2209+
sessions.write().remove(&challenge_id);
2210+
warn!("Auth token expired for container {}, will re-authenticate on next message", container_name);
2211+
}
21362212
Ok(resp) => {
21372213
debug!("Container {} returned {}", container_name, resp.status());
21382214
}
@@ -2480,3 +2556,128 @@ async fn discover_routes(url: &str) -> anyhow::Result<RoutesManifestResponse> {
24802556
let manifest: RoutesManifestResponse = response.json().await?;
24812557
Ok(manifest)
24822558
}
2559+
2560+
/// Authenticate with a challenge container
2561+
/// Returns the session token on success
2562+
async fn authenticate_with_container(
2563+
base_url: &str,
2564+
challenge_id: &str,
2565+
keypair: &Keypair,
2566+
) -> anyhow::Result<ContainerAuthSession> {
2567+
let client = reqwest::Client::builder()
2568+
.timeout(std::time::Duration::from_secs(30))
2569+
.build()?;
2570+
2571+
let timestamp = SystemTime::now()
2572+
.duration_since(UNIX_EPOCH)
2573+
.unwrap()
2574+
.as_secs();
2575+
2576+
// Generate random nonce
2577+
let nonce: [u8; 32] = rand::random();
2578+
let nonce_hex = hex::encode(nonce);
2579+
2580+
// Create message to sign: "auth:{challenge_id}:{timestamp}:{nonce}"
2581+
let message = format!("auth:{}:{}:{}", challenge_id, timestamp, nonce_hex);
2582+
2583+
// Sign the message
2584+
let signed = keypair.sign(message.as_bytes());
2585+
let signature_hex = hex::encode(&signed.signature);
2586+
2587+
let auth_request = ContainerAuthRequest {
2588+
hotkey: keypair.hotkey().to_hex(),
2589+
challenge_id: challenge_id.to_string(),
2590+
timestamp,
2591+
nonce: nonce_hex,
2592+
signature: signature_hex,
2593+
};
2594+
2595+
let auth_url = format!("{}/auth", base_url.trim_end_matches('/'));
2596+
2597+
info!(
2598+
"Authenticating with container at {} for challenge {}",
2599+
auth_url, challenge_id
2600+
);
2601+
2602+
let response = client.post(&auth_url).json(&auth_request).send().await?;
2603+
2604+
if !response.status().is_success() {
2605+
let status = response.status();
2606+
let body = response.text().await.unwrap_or_default();
2607+
anyhow::bail!(
2608+
"Container authentication failed with status {}: {}",
2609+
status,
2610+
body
2611+
);
2612+
}
2613+
2614+
let auth_response: ContainerAuthResponse = response.json().await?;
2615+
2616+
if !auth_response.success {
2617+
anyhow::bail!(
2618+
"Container authentication rejected: {}",
2619+
auth_response
2620+
.error
2621+
.unwrap_or_else(|| "Unknown error".to_string())
2622+
);
2623+
}
2624+
2625+
let token = auth_response
2626+
.session_token
2627+
.ok_or_else(|| anyhow::anyhow!("No session token in auth response"))?;
2628+
2629+
let expires_at = auth_response.expires_at.unwrap_or(timestamp + 3600); // Default 1 hour
2630+
2631+
let container_name = challenge_id.to_lowercase().replace([' ', '_'], "-");
2632+
2633+
info!(
2634+
"Successfully authenticated with container {} (expires at {})",
2635+
container_name, expires_at
2636+
);
2637+
2638+
Ok(ContainerAuthSession {
2639+
token,
2640+
expires_at,
2641+
container_name,
2642+
})
2643+
}
2644+
2645+
/// Check if a session is still valid (not expired)
2646+
fn is_session_valid(session: &ContainerAuthSession) -> bool {
2647+
let now = SystemTime::now()
2648+
.duration_since(UNIX_EPOCH)
2649+
.unwrap()
2650+
.as_secs();
2651+
2652+
// Add 60 second buffer before expiry
2653+
now < session.expires_at.saturating_sub(60)
2654+
}
2655+
2656+
/// Get or refresh authentication session for a container
2657+
async fn get_container_auth(
2658+
sessions: &RwLock<HashMap<String, ContainerAuthSession>>,
2659+
base_url: &str,
2660+
challenge_id: &str,
2661+
keypair: &Keypair,
2662+
) -> anyhow::Result<String> {
2663+
// Check if we have a valid session
2664+
{
2665+
let sessions_read = sessions.read();
2666+
if let Some(session) = sessions_read.get(challenge_id) {
2667+
if is_session_valid(session) {
2668+
return Ok(session.token.clone());
2669+
}
2670+
}
2671+
}
2672+
2673+
// Need to authenticate or re-authenticate
2674+
let session = authenticate_with_container(base_url, challenge_id, keypair).await?;
2675+
let token = session.token.clone();
2676+
2677+
{
2678+
let mut sessions_write = sessions.write();
2679+
sessions_write.insert(challenge_id.to_string(), session);
2680+
}
2681+
2682+
Ok(token)
2683+
}

0 commit comments

Comments
 (0)