From 9ff5cf8857bf6e6a98c2d8964c758156d3d75d1d Mon Sep 17 00:00:00 2001 From: echobt Date: Tue, 17 Feb 2026 16:42:13 +0000 Subject: [PATCH 1/2] feat(auth): require API key alongside hotkey for authentication Add a WORKER_API_KEY environment variable (required) that whitelisted hotkeys must provide via the X-Api-Key HTTP header to access protected endpoints. Authentication now requires both a valid whitelisted hotkey (X-Hotkey) and a matching API key (X-Api-Key) for submitting batches via POST /submit. This adds a second layer of request validation controlled by the worker operator. Changes: - auth.rs: Add api_key field to AuthHeaders, extract X-Api-Key header in extract_auth_headers, add InvalidApiKey variant to AuthError, and validate the API key in verify_request against the expected value. Added tests for invalid API key rejection and missing header detection. - config.rs: Add worker_api_key field to Config, loaded from WORKER_API_KEY env var (panics if unset). Log confirmation at startup. - handlers.rs: Pass worker_api_key from config to verify_request, update error message to list X-Api-Key as a required header. - AGENTS.md: Document WORKER_API_KEY env var and updated auth flow. - Cargo.lock: Version bump to 1.1.0. --- AGENTS.md | 3 ++- Cargo.lock | 2 +- src/auth.rs | 72 +++++++++++++++++++++++++++++++++++++++---------- src/config.rs | 6 +++++ src/handlers.rs | 8 ++++-- 5 files changed, 73 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7ea3871..04d1a3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 60496ed..78e393e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1951,7 +1951,7 @@ dependencies = [ [[package]] name = "term-executor" -version = "0.2.0" +version = "1.1.0" dependencies = [ "anyhow", "axum", diff --git a/src/auth.rs b/src/auth.rs index 1d56191..26003ca 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -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 { @@ -62,18 +63,33 @@ pub fn extract_auth_headers(headers: &axum::http::HeaderMap) -> Option 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); } @@ -94,6 +110,7 @@ pub fn verify_request(auth: &AuthHeaders, nonce_store: &NonceStore) -> Result<() pub enum AuthError { UnauthorizedHotkey, InvalidHotkey, + InvalidApiKey, NonceReused, InvalidSignature, } @@ -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", } @@ -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", } @@ -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::*; @@ -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] @@ -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}; @@ -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 -} diff --git a/src/config.rs b/src/config.rs index 9ceee06..aec90af 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 { @@ -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"), } } @@ -62,6 +65,7 @@ impl Config { "║ Workspace: {:<28}║", self.workspace_base.display() ); + tracing::info!("║ API key: {:<28}║", "configured"); tracing::info!("╚══════════════════════════════════════════════════╝"); } } @@ -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] diff --git a/src/handlers.rs b/src/handlers.rs index 29b33ac..bcf45a3 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -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!({ From 5f7b0c0d039bd621a995332d4e1d909f8d2a29fe Mon Sep 17 00:00:00 2001 From: echobt Date: Tue, 17 Feb 2026 16:44:24 +0000 Subject: [PATCH 2/2] ci: trigger CI run