diff --git a/net-guardia/Cargo.toml b/net-guardia/Cargo.toml index 992f18a..a98a03a 100644 --- a/net-guardia/Cargo.toml +++ b/net-guardia/Cargo.toml @@ -67,6 +67,7 @@ r2d2_sqlite = "0.27" jsonwebtoken = { workspace = true } argon2 = { workspace = true } sha2 = "0.10" +hmac = "0.12" aes-gcm = "0.10" hkdf = "0.12" base64 = { workspace = true } diff --git a/net-guardia/src/adapter/http/api_keys.rs b/net-guardia/src/adapter/http/api_keys.rs index cc7a4ea..5b4bcf7 100644 --- a/net-guardia/src/adapter/http/api_keys.rs +++ b/net-guardia/src/adapter/http/api_keys.rs @@ -52,12 +52,7 @@ async fn generate_key( .map(char::from) .collect(); - use sha2::{Digest, Sha256}; - let key_hash = { - let mut hasher = Sha256::new(); - hasher.update(raw_key.as_bytes()); - format!("{:x}", hasher.finalize()) - }; + let key_hash = db.hmac_api_key(&raw_key); let level = body.level.as_deref().unwrap_or("read_only"); if !matches!(level, "read_only" | "read_write" | "full_access") { diff --git a/net-guardia/src/adapter/http/system.rs b/net-guardia/src/adapter/http/system.rs index d649809..c80069c 100644 --- a/net-guardia/src/adapter/http/system.rs +++ b/net-guardia/src/adapter/http/system.rs @@ -1,6 +1,7 @@ use actix_web::{HttpResponse, Responder, Scope, web}; use serde::Deserialize; +use crate::core::auth::extractor::AuthClaims; use crate::core::config_service::ConfigService; use crate::core::system::{ShutdownHandle, ShutdownMode}; use crate::infrastructure::communication_manager::CommunicationManager; @@ -133,7 +134,10 @@ async fn update_config( } } -async fn shutdown(handle: web::Data) -> impl Responder { +async fn shutdown(auth: AuthClaims, handle: web::Data) -> impl Responder { + if !auth.permissions.iter().any(|p| p == "system:admin") { + return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"})); + } if handle.trigger(ShutdownMode::Shutdown) { HttpResponse::Ok().json(serde_json::json!({"message": "Shutdown initiated"})) } else { @@ -141,7 +145,10 @@ async fn shutdown(handle: web::Data) -> impl Responder { } } -async fn restart(handle: web::Data) -> impl Responder { +async fn restart(auth: AuthClaims, handle: web::Data) -> impl Responder { + if !auth.permissions.iter().any(|p| p == "system:admin") { + return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"})); + } if handle.trigger(ShutdownMode::Restart) { HttpResponse::Ok().json(serde_json::json!({"message": "Restart initiated"})) } else { diff --git a/net-guardia/src/adapter/persistence/repository.rs b/net-guardia/src/adapter/persistence/repository.rs index de80598..348e4ab 100644 --- a/net-guardia/src/adapter/persistence/repository.rs +++ b/net-guardia/src/adapter/persistence/repository.rs @@ -48,6 +48,8 @@ pub struct AuditLogEntry { pub struct Database { pool: Pool, + /// HMAC-SHA256 key for API key hashing, derived from NETGUARDIA_SECRETS_KEY. + api_key_hmac: [u8; 32], } impl Database { @@ -92,11 +94,50 @@ impl Database { })?; } - let db = Self { pool }; + let api_key_hmac = Self::derive_api_key_hmac(); + let db = Self { pool, api_key_hmac }; db.create_tables()?; Ok(db) } + /// Derive HMAC-SHA256 key for API key hashing from NETGUARDIA_SECRETS_KEY. + /// Falls back to a static dev key if the env var is unset. + fn derive_api_key_hmac() -> [u8; 32] { + use hkdf::Hkdf; + use sha2::Sha256; + + let root_key = std::env::var("NETGUARDIA_SECRETS_KEY") + .ok() + .filter(|k| !k.is_empty()) + .or_else(|| std::env::var("NETGUARDIA_DB_KEY").ok().filter(|k| !k.is_empty())) + .unwrap_or_else(|| "netguardia-dev-api-key-secret".to_string()); + + let hk = Hkdf::::new(Some(b"netguardia-v1-salt"), root_key.as_bytes()); + let mut okm = [0u8; 32]; + // SAFETY: 32 bytes is a valid output length for HKDF-SHA256 + hk.expand(b"netguardia-apikey-hmac-v1", &mut okm).unwrap(); + okm + } + + /// Compute HMAC-SHA256 of an API key using the derived secret. + pub fn hmac_api_key(&self, raw_key: &str) -> String { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + use std::fmt::Write; + + type HmacSha256 = Hmac; + let mut mac = HmacSha256::new_from_slice(&self.api_key_hmac) + .unwrap_or_else(|_| unreachable!()); + mac.update(raw_key.as_bytes()); + let result = mac.finalize().into_bytes(); + + let mut hex = String::with_capacity(64); + for byte in result { + let _ = write!(&mut hex, "{:02x}", byte); + } + hex + } + /// One-time migration: if the DB file exists and is a *plaintext* SQLite database /// (i.e. opening it with the encryption key fails, but opening without a key /// succeeds), export it to a new encrypted file and atomically replace the original. @@ -414,6 +455,7 @@ impl Database { "protocol_filter:write", "system:read", "system:write", + "system:admin", "users:read", "users:write", "users:admin" @@ -1047,22 +1089,9 @@ impl Database { // --- MCP API Keys --- /// Validate an API key and return Claims if valid. - /// Computes SHA-256 hash of the key and looks it up in api_keys table. + /// Computes HMAC-SHA256 of the key and looks it up in api_keys table. pub fn validate_api_key(&self, api_key: &str) -> Result, Error> { - use std::fmt::Write; - - // SHA-256 hash the key - let digest = { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(api_key.as_bytes()); - let result = hasher.finalize(); - let mut hex = String::with_capacity(64); - for byte in result { - write!(&mut hex, "{:02x}", byte).unwrap(); - } - hex - }; + let digest = self.hmac_api_key(api_key); let conn = self.conn()?; let result = conn.query_row( @@ -2029,6 +2058,9 @@ impl crate::interface::port::api_key::ApiKeyPort for Database { fn validate_api_key(&self, api_key: &str) -> Result, Error> { self.validate_api_key(api_key) } + fn hmac_api_key(&self, raw_key: &str) -> String { + self.hmac_api_key(raw_key) + } fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result { self.insert_api_key(key_hash, name, permission_level) } diff --git a/net-guardia/src/interface/port/api_key.rs b/net-guardia/src/interface/port/api_key.rs index 94e4af2..9bedc15 100644 --- a/net-guardia/src/interface/port/api_key.rs +++ b/net-guardia/src/interface/port/api_key.rs @@ -8,6 +8,7 @@ pub type ApiKeyListItem = (i64, String, String, String, Option); /// Port for API key management and validation. pub trait ApiKeyPort: Send + Sync { fn validate_api_key(&self, api_key: &str) -> Result, Error>; + fn hmac_api_key(&self, raw_key: &str) -> String; fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result; fn list_api_keys(&self) -> Result, Error>; fn delete_api_key(&self, id: i64) -> Result;