Skip to content

Commit ed82d98

Browse files
committed
feat: add sr25519 signature auth for challenge routes
1 parent 8af5e4f commit ed82d98

File tree

5 files changed

+191
-9
lines changed

5 files changed

+191
-9
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.

crates/rpc-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ anyhow = { workspace = true }
3535
parking_lot = { workspace = true }
3636
uuid = { workspace = true }
3737
chrono = { workspace = true }
38+
sha2.workspace = true
3839

3940
[dev-dependencies]
4041
tempfile = { workspace = true }

crates/rpc-server/src/auth.rs

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
//! Authentication for RPC requests
22
//!
33
//! 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.
48
59
use platform_core::Hotkey;
10+
use sha2::{Digest, Sha256};
611
use sp_core::{crypto::Pair as _, sr25519};
7-
use tracing::warn;
12+
use std::collections::HashMap;
13+
use tracing::{debug, warn};
814

915
/// Verify a signed message from a validator (sr25519)
1016
pub fn verify_validator_signature(
@@ -62,6 +68,92 @@ pub enum AuthError {
6268

6369
#[error("Message expired")]
6470
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+
)
65157
}
66158

67159
#[cfg(test)]
@@ -172,4 +264,81 @@ mod tests {
172264
let result = verify_validator_signature(&kp.hotkey().to_hex(), message, &long_sig);
173265
assert!(result.is_err());
174266
}
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+
}
175344
}

crates/rpc-server/src/jsonrpc.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,19 +1186,24 @@ impl RpcHandler {
11861186
}
11871187
}
11881188

1189-
// Optional auth_hotkey parameter for authenticated routes
1190-
let auth_hotkey = params
1191-
.get("authHotkey")
1192-
.or_else(|| params.get(5))
1193-
.and_then(|v| v.as_str())
1194-
.map(|s| s.to_string());
1189+
// Parse headers for authentication
1190+
let headers: std::collections::HashMap<String, String> = params
1191+
.get("headers")
1192+
.and_then(|v| serde_json::from_value(v.clone()).ok())
1193+
.unwrap_or_default();
1194+
1195+
// Verify authentication from headers if present
1196+
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
1197+
let auth_hotkey =
1198+
crate::auth::verify_route_auth(&headers, &challenge_id, &method, &path, &body_bytes)
1199+
.ok();
11951200

11961201
let request = RouteRequest {
11971202
method,
11981203
path,
11991204
params: std::collections::HashMap::new(),
12001205
query,
1201-
headers: std::collections::HashMap::new(),
1206+
headers,
12021207
body,
12031208
auth_hotkey,
12041209
};

crates/rpc-server/src/server.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,14 +393,20 @@ async fn challenge_route_handler(
393393
}
394394
}
395395

396+
// Verify authentication from headers if present
397+
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
398+
let auth_hotkey =
399+
crate::auth::verify_route_auth(&headers_map, &challenge_id, &method, &path, &body_bytes)
400+
.ok();
401+
396402
let request = RouteRequest {
397403
method,
398404
path,
399405
params,
400406
query,
401407
headers: headers_map,
402408
body,
403-
auth_hotkey: None,
409+
auth_hotkey,
404410
};
405411

406412
// Call the route handler if registered

0 commit comments

Comments
 (0)