|
1 | 1 | //! Authentication for RPC requests |
2 | 2 | //! |
3 | 3 | //! Validators authenticate using their hotkey signature (sr25519). |
| 4 | +//! |
| 5 | +//! For challenge routes, the signed message format is: |
| 6 | +//! `challenge:{challenge_id}:{method}:{path}:{body_hash}:{nonce}` |
| 7 | +//! where body_hash is SHA256 of the request body. |
4 | 8 |
|
5 | 9 | use platform_core::Hotkey; |
| 10 | +use sha2::{Digest, Sha256}; |
6 | 11 | use sp_core::{crypto::Pair as _, sr25519}; |
7 | | -use tracing::warn; |
| 12 | +use std::collections::HashMap; |
| 13 | +use tracing::{debug, warn}; |
8 | 14 |
|
9 | 15 | /// Verify a signed message from a validator (sr25519) |
10 | 16 | pub fn verify_validator_signature( |
@@ -62,6 +68,92 @@ pub enum AuthError { |
62 | 68 |
|
63 | 69 | #[error("Message expired")] |
64 | 70 | MessageExpired, |
| 71 | + |
| 72 | + #[error("Missing authentication header")] |
| 73 | + MissingHeader, |
| 74 | + |
| 75 | + #[error("Invalid nonce format")] |
| 76 | + InvalidNonce, |
| 77 | +} |
| 78 | + |
| 79 | +/// Verify challenge route authentication from headers |
| 80 | +/// |
| 81 | +/// Expected headers (case-insensitive): |
| 82 | +/// - `x-hotkey`: Hotkey public key (hex, 64 chars) |
| 83 | +/// - `x-signature`: sr25519 signature (hex, 128 chars) |
| 84 | +/// - `x-nonce`: Unique nonce containing timestamp (format: `{timestamp}:{random}`) |
| 85 | +/// |
| 86 | +/// The signed message format is: |
| 87 | +/// `challenge:{challenge_id}:{method}:{path}:{body_hash}:{nonce}` |
| 88 | +pub fn verify_route_auth( |
| 89 | + headers: &HashMap<String, String>, |
| 90 | + challenge_id: &str, |
| 91 | + method: &str, |
| 92 | + path: &str, |
| 93 | + body: &[u8], |
| 94 | +) -> Result<String, AuthError> { |
| 95 | + // Headers are case-insensitive, normalize to lowercase |
| 96 | + let headers_lower: HashMap<String, String> = headers |
| 97 | + .iter() |
| 98 | + .map(|(k, v)| (k.to_lowercase(), v.clone())) |
| 99 | + .collect(); |
| 100 | + |
| 101 | + let hotkey = headers_lower |
| 102 | + .get("x-hotkey") |
| 103 | + .ok_or(AuthError::MissingHeader)?; |
| 104 | + let signature = headers_lower |
| 105 | + .get("x-signature") |
| 106 | + .ok_or(AuthError::MissingHeader)?; |
| 107 | + let nonce = headers_lower |
| 108 | + .get("x-nonce") |
| 109 | + .ok_or(AuthError::MissingHeader)?; |
| 110 | + |
| 111 | + // Verify nonce contains valid timestamp (anti-replay) |
| 112 | + let timestamp: i64 = nonce |
| 113 | + .split(':') |
| 114 | + .next() |
| 115 | + .and_then(|t| t.parse().ok()) |
| 116 | + .ok_or(AuthError::InvalidNonce)?; |
| 117 | + |
| 118 | + if !verify_timestamp(timestamp) { |
| 119 | + return Err(AuthError::MessageExpired); |
| 120 | + } |
| 121 | + |
| 122 | + // Hash the body for signature verification |
| 123 | + let body_hash = hex::encode(Sha256::digest(body)); |
| 124 | + |
| 125 | + // Build the signed message |
| 126 | + let message = format!( |
| 127 | + "challenge:{}:{}:{}:{}:{}", |
| 128 | + challenge_id, method, path, body_hash, nonce |
| 129 | + ); |
| 130 | + |
| 131 | + debug!( |
| 132 | + hotkey = %&hotkey[..16.min(hotkey.len())], |
| 133 | + method = %method, |
| 134 | + path = %path, |
| 135 | + "Verifying route authentication" |
| 136 | + ); |
| 137 | + |
| 138 | + match verify_validator_signature(hotkey, &message, signature)? { |
| 139 | + true => Ok(hotkey.clone()), |
| 140 | + false => Err(AuthError::VerificationFailed), |
| 141 | + } |
| 142 | +} |
| 143 | + |
| 144 | +/// Create a challenge route auth message for signing |
| 145 | +pub fn create_route_auth_message( |
| 146 | + challenge_id: &str, |
| 147 | + method: &str, |
| 148 | + path: &str, |
| 149 | + body: &[u8], |
| 150 | + nonce: &str, |
| 151 | +) -> String { |
| 152 | + let body_hash = hex::encode(Sha256::digest(body)); |
| 153 | + format!( |
| 154 | + "challenge:{}:{}:{}:{}:{}", |
| 155 | + challenge_id, method, path, body_hash, nonce |
| 156 | + ) |
65 | 157 | } |
66 | 158 |
|
67 | 159 | #[cfg(test)] |
@@ -172,4 +264,81 @@ mod tests { |
172 | 264 | let result = verify_validator_signature(&kp.hotkey().to_hex(), message, &long_sig); |
173 | 265 | assert!(result.is_err()); |
174 | 266 | } |
| 267 | + |
| 268 | + #[test] |
| 269 | + fn test_verify_route_auth_success() { |
| 270 | + let kp = Keypair::generate(); |
| 271 | + let challenge_id = "test-challenge-id"; |
| 272 | + let method = "POST"; |
| 273 | + let path = "/register"; |
| 274 | + let body = b"test body"; |
| 275 | + let nonce = format!("{}:random123", chrono::Utc::now().timestamp()); |
| 276 | + |
| 277 | + // Create the signed message |
| 278 | + let message = create_route_auth_message(challenge_id, method, path, body, &nonce); |
| 279 | + let signed = kp.sign(message.as_bytes()); |
| 280 | + |
| 281 | + let mut headers = HashMap::new(); |
| 282 | + headers.insert("x-hotkey".to_string(), kp.hotkey().to_hex()); |
| 283 | + headers.insert("x-signature".to_string(), hex::encode(&signed.signature)); |
| 284 | + headers.insert("x-nonce".to_string(), nonce); |
| 285 | + |
| 286 | + let result = verify_route_auth(&headers, challenge_id, method, path, body); |
| 287 | + assert!(result.is_ok()); |
| 288 | + assert_eq!(result.unwrap(), kp.hotkey().to_hex()); |
| 289 | + } |
| 290 | + |
| 291 | + #[test] |
| 292 | + fn test_verify_route_auth_missing_header() { |
| 293 | + let headers = HashMap::new(); |
| 294 | + let result = verify_route_auth(&headers, "challenge", "GET", "/", b""); |
| 295 | + assert!(matches!(result, Err(AuthError::MissingHeader))); |
| 296 | + } |
| 297 | + |
| 298 | + #[test] |
| 299 | + fn test_verify_route_auth_expired() { |
| 300 | + let kp = Keypair::generate(); |
| 301 | + let old_timestamp = chrono::Utc::now().timestamp() - 600; // 10 minutes ago |
| 302 | + let nonce = format!("{}:random", old_timestamp); |
| 303 | + |
| 304 | + let message = create_route_auth_message("challenge", "GET", "/", b"", &nonce); |
| 305 | + let signed = kp.sign(message.as_bytes()); |
| 306 | + |
| 307 | + let mut headers = HashMap::new(); |
| 308 | + headers.insert("x-hotkey".to_string(), kp.hotkey().to_hex()); |
| 309 | + headers.insert("x-signature".to_string(), hex::encode(&signed.signature)); |
| 310 | + headers.insert("x-nonce".to_string(), nonce); |
| 311 | + |
| 312 | + let result = verify_route_auth(&headers, "challenge", "GET", "/", b""); |
| 313 | + assert!(matches!(result, Err(AuthError::MessageExpired))); |
| 314 | + } |
| 315 | + |
| 316 | + #[test] |
| 317 | + fn test_verify_route_auth_wrong_body() { |
| 318 | + let kp = Keypair::generate(); |
| 319 | + let nonce = format!("{}:random", chrono::Utc::now().timestamp()); |
| 320 | + |
| 321 | + // Sign with one body |
| 322 | + let message = create_route_auth_message("challenge", "POST", "/", b"body1", &nonce); |
| 323 | + let signed = kp.sign(message.as_bytes()); |
| 324 | + |
| 325 | + let mut headers = HashMap::new(); |
| 326 | + headers.insert("x-hotkey".to_string(), kp.hotkey().to_hex()); |
| 327 | + headers.insert("x-signature".to_string(), hex::encode(&signed.signature)); |
| 328 | + headers.insert("x-nonce".to_string(), nonce); |
| 329 | + |
| 330 | + // Verify with different body - should fail |
| 331 | + let result = verify_route_auth(&headers, "challenge", "POST", "/", b"body2"); |
| 332 | + assert!(matches!(result, Err(AuthError::VerificationFailed))); |
| 333 | + } |
| 334 | + |
| 335 | + #[test] |
| 336 | + fn test_create_route_auth_message() { |
| 337 | + let msg = create_route_auth_message("cid", "POST", "/path", b"body", "123:abc"); |
| 338 | + let body_hash = hex::encode(Sha256::digest(b"body")); |
| 339 | + assert_eq!( |
| 340 | + msg, |
| 341 | + format!("challenge:cid:POST:/path:{}:123:abc", body_hash) |
| 342 | + ); |
| 343 | + } |
175 | 344 | } |
0 commit comments