chore: seed sgclaw rust baseline
This commit is contained in:
48
src/security/hmac.rs
Normal file
48
src/security/hmac.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::pipe::Action;
|
||||
use crate::security::SecurityError;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub fn derive_session_key(hmac_seed: &str) -> Result<Vec<u8>, SecurityError> {
|
||||
let seed = hex::decode(hmac_seed)?;
|
||||
if seed.is_empty() {
|
||||
return Err(SecurityError::InvalidSeed(
|
||||
"hmac_seed must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(seed);
|
||||
hasher.update(b"sgclaw-session-v1");
|
||||
Ok(hasher.finalize().to_vec())
|
||||
}
|
||||
|
||||
pub fn sign_command(
|
||||
session_key: &[u8],
|
||||
seq: u64,
|
||||
action: &Action,
|
||||
params: &Value,
|
||||
expected_domain: &str,
|
||||
) -> Result<String, SecurityError> {
|
||||
if session_key.is_empty() {
|
||||
return Err(SecurityError::InvalidSeed(
|
||||
"session key must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(session_key)
|
||||
.map_err(|err| SecurityError::Hmac(err.to_string()))?;
|
||||
mac.update(seq.to_string().as_bytes());
|
||||
mac.update(b"|");
|
||||
mac.update(action.as_str().as_bytes());
|
||||
mac.update(b"|");
|
||||
mac.update(expected_domain.as_bytes());
|
||||
mac.update(b"|");
|
||||
mac.update(serde_json::to_string(params)?.as_bytes());
|
||||
|
||||
Ok(hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
111
src/security/mac_policy.rs
Normal file
111
src/security/mac_policy.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::pipe::Action;
|
||||
use crate::security::SecurityError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MacPolicy {
|
||||
pub version: String,
|
||||
pub domains: DomainRules,
|
||||
pub pipe_actions: PipeActionRules,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DomainRules {
|
||||
pub allowed: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PipeActionRules {
|
||||
pub allowed: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub blocked: Vec<String>,
|
||||
}
|
||||
|
||||
impl MacPolicy {
|
||||
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, SecurityError> {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let policy: Self = serde_json::from_str(&contents)?;
|
||||
policy.validate_rules()?;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
pub fn from_json_str(contents: &str) -> Result<Self, SecurityError> {
|
||||
let policy: Self = serde_json::from_str(contents)?;
|
||||
policy.validate_rules()?;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
pub fn validate(&self, action: &Action, expected_domain: &str) -> Result<(), SecurityError> {
|
||||
let action_name = action.as_str();
|
||||
if self
|
||||
.pipe_actions
|
||||
.blocked
|
||||
.iter()
|
||||
.any(|blocked| blocked == action_name)
|
||||
{
|
||||
return Err(SecurityError::ActionNotAllowed(action_name.to_string()));
|
||||
}
|
||||
|
||||
if !self
|
||||
.pipe_actions
|
||||
.allowed
|
||||
.iter()
|
||||
.any(|allowed| allowed == action_name)
|
||||
{
|
||||
return Err(SecurityError::ActionNotAllowed(action_name.to_string()));
|
||||
}
|
||||
|
||||
let normalized = normalize_domain(expected_domain);
|
||||
if normalized.is_empty() {
|
||||
return Err(SecurityError::DomainNotAllowed(expected_domain.to_string()));
|
||||
}
|
||||
|
||||
if !self
|
||||
.domains
|
||||
.allowed
|
||||
.iter()
|
||||
.map(|domain| normalize_domain(domain))
|
||||
.any(|allowed| allowed == normalized)
|
||||
{
|
||||
return Err(SecurityError::DomainNotAllowed(normalized));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_rules(&self) -> Result<(), SecurityError> {
|
||||
if self.version.trim().is_empty() {
|
||||
return Err(SecurityError::InvalidRules(
|
||||
"rules version must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.domains.allowed.is_empty() {
|
||||
return Err(SecurityError::InvalidRules(
|
||||
"at least one allowed domain is required".to_string(),
|
||||
));
|
||||
}
|
||||
if self.pipe_actions.allowed.is_empty() {
|
||||
return Err(SecurityError::InvalidRules(
|
||||
"at least one allowed action is required".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_domain(raw: &str) -> String {
|
||||
raw.trim()
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
27
src/security/mod.rs
Normal file
27
src/security/mod.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
mod hmac;
|
||||
mod mac_policy;
|
||||
|
||||
pub use hmac::{derive_session_key, sign_command};
|
||||
pub use mac_policy::MacPolicy;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SecurityError {
|
||||
#[error("invalid hmac seed: {0}")]
|
||||
InvalidSeed(String),
|
||||
#[error("action is not allowed: {0}")]
|
||||
ActionNotAllowed(String),
|
||||
#[error("domain is not allowed: {0}")]
|
||||
DomainNotAllowed(String),
|
||||
#[error("invalid rules: {0}")]
|
||||
InvalidRules(String),
|
||||
#[error("hmac error: {0}")]
|
||||
Hmac(String),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error(transparent)]
|
||||
Hex(#[from] hex::FromHexError),
|
||||
}
|
||||
Reference in New Issue
Block a user