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:
DaLaw2 2026-04-03 19:21:26 +08:00
parent f972ab69be
commit a7f74999ff
5 changed files with 60 additions and 24 deletions

View File

@ -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 }

View File

@ -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") {

View File

@ -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 {

View File

@ -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)
}

View File

@ -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>;