mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
feat: HMAC-SHA256 API key hashing, system:admin shutdown permission
API-KEY-HASH: - Replace unsalted SHA-256 with HMAC-SHA256 for API key storage - HMAC key derived from NETGUARDIA_SECRETS_KEY via HKDF (info: "netguardia-apikey-hmac-v1") - Falls back to static dev key when env var unset - Add hmac_api_key() to Database and ApiKeyPort trait - Add hmac crate dependency SHUTDOWN-PERM: - New system:admin permission gates shutdown/restart endpoints - Added to Administrator group default permissions seed - shutdown() and restart() handlers now require AuthClaims with system:admin - Returns 403 Forbidden without the permission - API keys intentionally excluded from system:admin (no remote shutdown via API key) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f972ab69be
commit
a7f74999ff
@ -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 }
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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<ShutdownHandle>) -> impl Responder {
|
||||
async fn shutdown(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> 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<ShutdownHandle>) -> impl Responder {
|
||||
}
|
||||
}
|
||||
|
||||
async fn restart(handle: web::Data<ShutdownHandle>) -> impl Responder {
|
||||
async fn restart(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> 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 {
|
||||
|
||||
@ -48,6 +48,8 @@ pub struct AuditLogEntry {
|
||||
|
||||
pub struct Database {
|
||||
pool: Pool<SqliteConnectionManager>,
|
||||
/// 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::<Sha256>::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<Sha256>;
|
||||
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<Option<crate::model::identity::auth::Claims>, 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<Option<crate::model::identity::auth::Claims>, 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<i64, Error> {
|
||||
self.insert_api_key(key_hash, name, permission_level)
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ pub type ApiKeyListItem = (i64, String, String, String, Option<String>);
|
||||
/// Port for API key management and validation.
|
||||
pub trait ApiKeyPort: Send + Sync {
|
||||
fn validate_api_key(&self, api_key: &str) -> Result<Option<Claims>, Error>;
|
||||
fn hmac_api_key(&self, raw_key: &str) -> String;
|
||||
fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result<i64, Error>;
|
||||
fn list_api_keys(&self) -> Result<Vec<ApiKeyListItem>, Error>;
|
||||
fn delete_api_key(&self, id: i64) -> Result<bool, Error>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user