Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ Both hooks are activated via `git config core.hooksPath .githooks`.
| `MAX_ARCHIVE_BYTES` | `524288000` | Max uploaded archive size (500MB) |
| `MAX_OUTPUT_BYTES` | `1048576` | Max captured output per command (1MB) |
| `WORKSPACE_BASE` | `/tmp/sessions` | Base directory for session workspaces |
| `WORKER_API_KEY` | *(required)* | API key that whitelisted hotkeys must provide via `X-Api-Key` header |

## Authentication

Authentication uses SS58 hotkey validation via the `X-Hotkey` HTTP header. The authorized hotkey is hardcoded as `AUTHORIZED_HOTKEY` in `src/config.rs`. Only requests with a matching hotkey can submit batches via `POST /submit`. All other endpoints are open.
Authentication uses SS58 hotkey validation via the `X-Hotkey` HTTP header combined with an API key via the `X-Api-Key` header. The authorized hotkey is hardcoded as `AUTHORIZED_HOTKEY` in `src/config.rs`. The API key is configured via the `WORKER_API_KEY` environment variable (required). Only requests with both a matching hotkey and a valid API key can submit batches via `POST /submit`. All other endpoints are open.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 58 additions & 14 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub struct AuthHeaders {
pub hotkey: String,
pub nonce: String,
pub signature: String,
pub api_key: String,
}

pub fn extract_auth_headers(headers: &axum::http::HeaderMap) -> Option<AuthHeaders> {
Expand All @@ -62,18 +63,33 @@ pub fn extract_auth_headers(headers: &axum::http::HeaderMap) -> Option<AuthHeade
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())?;

let api_key = headers
.get("X-Api-Key")
.or_else(|| headers.get("x-api-key"))
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())?;

Some(AuthHeaders {
hotkey,
nonce,
signature,
api_key,
})
}

pub fn verify_request(auth: &AuthHeaders, nonce_store: &NonceStore) -> Result<(), AuthError> {
pub fn verify_request(
auth: &AuthHeaders,
nonce_store: &NonceStore,
expected_api_key: &str,
) -> Result<(), AuthError> {
if auth.hotkey != AUTHORIZED_HOTKEY {
return Err(AuthError::UnauthorizedHotkey);
}

if auth.api_key != expected_api_key {
return Err(AuthError::InvalidApiKey);
}

if !validate_ss58(&auth.hotkey) {
return Err(AuthError::InvalidHotkey);
}
Expand All @@ -94,6 +110,7 @@ pub fn verify_request(auth: &AuthHeaders, nonce_store: &NonceStore) -> Result<()
pub enum AuthError {
UnauthorizedHotkey,
InvalidHotkey,
InvalidApiKey,
NonceReused,
InvalidSignature,
}
Expand All @@ -103,6 +120,7 @@ impl AuthError {
match self {
AuthError::UnauthorizedHotkey => "Hotkey is not authorized",
AuthError::InvalidHotkey => "Invalid SS58 hotkey format",
AuthError::InvalidApiKey => "Invalid API key",
AuthError::NonceReused => "Nonce has already been used",
AuthError::InvalidSignature => "Signature verification failed",
}
Expand All @@ -112,6 +130,7 @@ impl AuthError {
match self {
AuthError::UnauthorizedHotkey => "unauthorized_hotkey",
AuthError::InvalidHotkey => "invalid_hotkey",
AuthError::InvalidApiKey => "invalid_api_key",
AuthError::NonceReused => "nonce_reused",
AuthError::InvalidSignature => "invalid_signature",
}
Expand Down Expand Up @@ -182,6 +201,18 @@ pub fn validate_ss58(address: &str) -> bool {
ss58_to_public_key_bytes(address).is_some()
}

#[cfg(test)]
fn sp_ss58_checksum(data: &[u8]) -> [u8; 64] {
use sha2::{Digest, Sha512};
let mut hasher = Sha512::new();
hasher.update(b"SS58PRE");
hasher.update(data);
let result = hasher.finalize();
let mut out = [0u8; 64];
out.copy_from_slice(&result);
out
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -219,12 +250,14 @@ mod tests {
headers.insert("X-Hotkey", AUTHORIZED_HOTKEY.parse().unwrap());
headers.insert("X-Nonce", "test-nonce-123".parse().unwrap());
headers.insert("X-Signature", "0xdeadbeef".parse().unwrap());
headers.insert("X-Api-Key", "my-secret-key".parse().unwrap());
let auth = extract_auth_headers(&headers);
assert!(auth.is_some());
let auth = auth.unwrap();
assert_eq!(auth.hotkey, AUTHORIZED_HOTKEY);
assert_eq!(auth.nonce, "test-nonce-123");
assert_eq!(auth.signature, "0xdeadbeef");
assert_eq!(auth.api_key, "my-secret-key");
}

#[test]
Expand All @@ -240,11 +273,34 @@ mod tests {
hotkey: "5InvalidHotkey".to_string(),
nonce: "nonce-1".to_string(),
signature: "0x00".to_string(),
api_key: "test-key".to_string(),
};
let err = verify_request(&auth, &store).unwrap_err();
let err = verify_request(&auth, &store, "test-key").unwrap_err();
assert!(matches!(err, AuthError::UnauthorizedHotkey));
}

#[test]
fn test_verify_request_invalid_api_key() {
let store = NonceStore::new();
let auth = AuthHeaders {
hotkey: AUTHORIZED_HOTKEY.to_string(),
nonce: "nonce-1".to_string(),
signature: "0x00".to_string(),
api_key: "wrong-key".to_string(),
};
let err = verify_request(&auth, &store, "correct-key").unwrap_err();
assert!(matches!(err, AuthError::InvalidApiKey));
}

#[test]
fn test_extract_auth_headers_missing_api_key() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("X-Hotkey", AUTHORIZED_HOTKEY.parse().unwrap());
headers.insert("X-Nonce", "test-nonce-123".parse().unwrap());
headers.insert("X-Signature", "0xdeadbeef".parse().unwrap());
assert!(extract_auth_headers(&headers).is_none());
}

#[test]
fn test_verify_sr25519_roundtrip() {
use schnorrkel::{Keypair, MiniSecretKey};
Expand Down Expand Up @@ -272,15 +328,3 @@ mod tests {
assert!(!verify_sr25519_signature(&ss58, "wrong-message", &sig_hex));
}
}

#[cfg(test)]
fn sp_ss58_checksum(data: &[u8]) -> [u8; 64] {
use sha2::{Digest, Sha512};
let mut hasher = Sha512::new();
hasher.update(b"SS58PRE");
hasher.update(data);
let result = hasher.finalize();
let mut out = [0u8; 64];
out.copy_from_slice(&result);
out
}
6 changes: 6 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pub struct Config {
#[allow(dead_code)]
pub max_output_bytes: usize,
pub workspace_base: PathBuf,
pub worker_api_key: String,
}

impl Config {
Expand All @@ -41,6 +42,8 @@ impl Config {
workspace_base: PathBuf::from(
std::env::var("WORKSPACE_BASE").unwrap_or_else(|_| DEFAULT_WORKSPACE_BASE.into()),
),
worker_api_key: std::env::var("WORKER_API_KEY")
.expect("WORKER_API_KEY environment variable must be set"),
}
}

Expand All @@ -62,6 +65,7 @@ impl Config {
"║ Workspace: {:<28}║",
self.workspace_base.display()
);
tracing::info!("║ API key: {:<28}║", "configured");
tracing::info!("╚══════════════════════════════════════════════════╝");
}
}
Expand All @@ -79,9 +83,11 @@ mod tests {

#[test]
fn test_config_defaults() {
std::env::set_var("WORKER_API_KEY", "test-api-key-123");
let cfg = Config::from_env();
assert_eq!(cfg.port, DEFAULT_PORT);
assert_eq!(cfg.max_concurrent_tasks, DEFAULT_MAX_CONCURRENT);
assert_eq!(cfg.worker_api_key, "test-api-key-123");
}

#[test]
Expand Down
8 changes: 6 additions & 2 deletions src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,16 @@ async fn submit_batch(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "missing_auth",
"message": "Missing required headers: X-Hotkey, X-Nonce, X-Signature"
"message": "Missing required headers: X-Hotkey, X-Nonce, X-Signature, X-Api-Key"
})),
)
})?;

if let Err(e) = auth::verify_request(&auth_headers, &state.nonce_store) {
if let Err(e) = auth::verify_request(
&auth_headers,
&state.nonce_store,
&state.config.worker_api_key,
) {
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
Expand Down