use serde::{Deserialize, Serialize};
use crate::error::Error;
use crate::security_config::SecurityConfig;
#[derive(Debug, Serialize)]
pub struct RelayRequest {
pub nonce: String,
pub timestamp: u64,
pub device_id: String,
pub binary: String,
pub secret: String,
pub env_var: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RelayResponse {
pub nonce: String,
#[serde(default)]
pub timestamp: Option<u64>,
pub decision: RelayDecision,
pub signature: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RelayDecision {
Allow,
Deny,
Timeout,
}
pub fn validate_relay_endpoint(url: &str) -> Result<(), Error> {
if url.chars().any(char::is_control) {
return Err(Error::CryptoFailure(
"relay endpoint must not contain control characters".to_string(),
));
}
let Some(delim) = url.find("://") else {
return Err(Error::CryptoFailure(
"relay endpoint must be a full URL with an https:// scheme (include https://…)"
.to_string(),
));
};
let scheme = &url[..delim];
if !scheme.eq_ignore_ascii_case("https") {
return Err(Error::CryptoFailure(
"relay endpoint must use https:// (plain http and other schemes are not allowed)"
.to_string(),
));
}
let rest = &url[delim + 3..];
let authority_end = rest.find(|c| "/?#".contains(c)).unwrap_or(rest.len());
let authority = &rest[..authority_end];
if authority.contains('@') {
return Err(Error::CryptoFailure(
"relay endpoint must not embed credentials (user:pass@ in URL)".to_string(),
));
}
Ok(())
}
pub fn is_required(config: &SecurityConfig) -> bool {
config.relay_required
&& config.relay_endpoint.is_some()
&& config.relay_device_id.is_some()
&& config.relay_pairing_key.is_some()
}
pub fn request_relay_approval(
config: &SecurityConfig,
binary: &str,
secret: &str,
env_var: &str,
) -> Result<RelayDecision, Error> {
if config.relay_required && config.relay_pairing_key.is_none() {
return Err(Error::CryptoFailure(
"relay is required but no pairing key exists; hard-failing".to_string(),
));
}
let endpoint = config
.relay_endpoint
.as_deref()
.ok_or_else(|| Error::CryptoFailure("relay endpoint not configured".to_string()))?;
validate_relay_endpoint(endpoint)?;
let device_id = config
.relay_device_id
.clone()
.ok_or_else(|| Error::CryptoFailure("relay device ID not configured".to_string()))?;
let curl = crate::guard::verify_gui_binary("curl").map_err(|e| {
Error::CryptoFailure(format!(
"relay HTTP client: need system curl (same trust rules as GUI binaries): {e}"
))
})?;
let nonce = generate_nonce();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let request = RelayRequest {
nonce: nonce.clone(),
timestamp,
device_id,
binary: binary.to_string(),
secret: secret.to_string(),
env_var: env_var.to_string(),
};
let body = serde_json::to_string(&request)
.map_err(|e| Error::CryptoFailure(format!("failed to serialize relay request: {e}")))?;
let result = std::process::Command::new(&curl)
.args([
"-sS",
"--max-time",
"65",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
&body,
&format!("{endpoint}/v1/approve"),
])
.output();
match result {
Ok(output) if output.status.success() => {
let response_text = String::from_utf8_lossy(&output.stdout);
let response: RelayResponse = serde_json::from_str(&response_text)
.map_err(|e| Error::CryptoFailure(format!("invalid relay response: {e}")))?;
if response.nonce != nonce {
return Err(Error::CryptoFailure(
"relay response nonce mismatch — possible replay attack".to_string(),
));
}
if response.decision == RelayDecision::Allow {
let pairing_hex = config.relay_pairing_key.as_deref().ok_or_else(|| {
Error::CryptoFailure(
"relay allow response cannot be verified without pairing key".to_string(),
)
})?;
verify_relay_hmac(
pairing_hex,
&response.nonce,
response.decision,
response.timestamp,
&response.signature,
)?;
}
match response.decision {
RelayDecision::Allow => Ok(RelayDecision::Allow),
RelayDecision::Deny | RelayDecision::Timeout => Err(Error::UserDenied),
}
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(relay_transport_error(stderr.as_ref()))
}
Err(e) => Err(Error::CryptoFailure(format!(
"failed to spawn relay HTTP client: {e}"
))),
}
}
fn relay_transport_error(stderr: &str) -> Error {
let verbose = std::env::var("ENVSEAL_RELAY_VERBOSE").is_ok_and(|v| {
matches!(
v.trim(),
"1" | "true" | "TRUE" | "yes" | "Yes" | "on" | "ON"
)
});
let msg = if verbose {
format!("relay transport or server error: {}", stderr.trim())
} else {
"relay transport or server error (set ENVSEAL_RELAY_VERBOSE=1 for details)".to_string()
};
Error::CryptoFailure(msg)
}
const RELAY_RESPONSE_MAX_SKEW_SECS: u64 = 120;
fn verify_relay_hmac(
pairing_key_hex: &str,
nonce: &str,
decision: RelayDecision,
response_ts: Option<u64>,
signature_hex: &str,
) -> Result<(), Error> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let key = decode_hex_bytes(pairing_key_hex)?;
let decision_str = match decision {
RelayDecision::Allow => "allow",
RelayDecision::Deny => "deny",
RelayDecision::Timeout => "timeout",
};
let sig_bytes = decode_hex_bytes(signature_hex)?;
if let Some(ts) = response_ts {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if ts.abs_diff(now) > RELAY_RESPONSE_MAX_SKEW_SECS {
return Err(Error::CryptoFailure(
"relay response timestamp outside allowed freshness window".to_string(),
));
}
let mut mac = HmacSha256::new_from_slice(&key)
.map_err(|e| Error::CryptoFailure(format!("relay HMAC init failed: {e}")))?;
mac.update(nonce.as_bytes());
mac.update(decision_str.as_bytes());
mac.update(&ts.to_be_bytes());
let mac_bytes = mac.finalize().into_bytes();
if sig_bytes.len() == mac_bytes.len()
&& crate::guard::constant_time_eq(mac_bytes.as_slice(), sig_bytes.as_slice())
{
return Ok(());
}
return Err(Error::CryptoFailure(
"relay response signature verification failed".to_string(),
));
}
let mut mac = HmacSha256::new_from_slice(&key)
.map_err(|e| Error::CryptoFailure(format!("relay HMAC init failed: {e}")))?;
mac.update(nonce.as_bytes());
mac.update(decision_str.as_bytes());
let mac_bytes = mac.finalize().into_bytes();
if sig_bytes.len() == mac_bytes.len()
&& crate::guard::constant_time_eq(mac_bytes.as_slice(), sig_bytes.as_slice())
{
return Ok(());
}
Err(Error::CryptoFailure(
"relay response signature verification failed".to_string(),
))
}
fn decode_hex_bytes(s: &str) -> Result<Vec<u8>, Error> {
if s.len() % 2 != 0 {
return Err(Error::CryptoFailure(
"invalid hex string length".to_string(),
));
}
(0..s.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&s[i..i + 2], 16)
.map_err(|_| Error::CryptoFailure("invalid hex digit".to_string()))
})
.collect()
}
fn generate_nonce() -> String {
use rand::RngCore;
use std::fmt::Write;
let mut buf = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut buf);
buf.iter().fold(String::with_capacity(64), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}