envseal 0.3.11

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Relay authentication — phone/device confirmation for vault operations.
//!
//! When `relay_required` is enabled in `SecurityConfig`, approval requests
//! are forwarded to a paired device (phone, tablet, second machine) via
//! a relay server. The device must confirm before the operation proceeds.
//!
//! # Protocol
//!
//! ```text
//! [Agent] -> envseal inject ... ->
//!   [envseal CLI] -> POST {base}/v1/approve  (JSON body) ->
//!     [Relay Server] -> push notification ->
//!       [Phone App] -> user taps Approve/Deny ->
//!     [Relay Server] <- JSON response <-
//!   [envseal CLI] <- verify HMAC on Allow, return decision
//! ```
//!
//! ## Server implementers
//!
//! The client posts to `{relay_endpoint}/v1/approve` with JSON including at
//! least: `nonce`, `timestamp`, `device_id`, `binary`, `secret`, `env_var`.
//! **Relay servers must accept `device_id`** and should use it to route the
//! request to the paired device. Unknown JSON fields on the request may be
//! added in future clients; servers should ignore them. The response must match
//! `RelayResponse`. For `decision: "allow"`, `signature` is hex HMAC-SHA256:
//! - If `timestamp` is **omitted** (legacy): MAC over `nonce || decision_ascii`.
//! - If `timestamp` is **set** (recommended): MAC over `nonce || decision_ascii || u64_be(timestamp)`,
//!   and the client enforces a freshness window against local clock.
//!
//! # Security Properties
//!
//! - **Transport**: requests use HTTPS to the relay URL you configure.
//!   The JSON body is visible to the relay operator unless you add an
//!   additional encryption layer out of band — treat the relay as trusted
//!   for metadata (binary path, secret name), not for secret values (those
//!   are never sent here).
//! - **Response integrity**: `Allow` decisions must carry an HMAC keyed by
//!   the pairing secret so a relay cannot forge approval. When `timestamp` is
//!   present, the MAC includes it so responses cannot be replayed outside a
//!   short freshness window.
//! - **Device-bound**: the pairing key is stored only on the phone
//!   and in the vault (encrypted with the master key).
//! - **Replay-resistant**: each challenge includes a nonce; relay responses
//!   should echo `timestamp` (Unix seconds) and sign it. The client rejects
//!   `Allow` responses whose timestamp skews more than ~120s from local time
//!   when `timestamp` is present, and rejects forged or stale MACs.
//! - **Offline fallback**: if the relay server is unreachable,
//!   [`crate::gui::request_approval`] falls back to local GUI (with a warning)
//!   when relay is not strictly required; when [`crate::relay::is_required`]
//!   is true, unreachable relay still surfaces as an error before that path.
//!
//! # Pairing Flow
//!
//! 1. User runs `envseal relay pair`
//! 2. CLI generates a 256-bit pairing key and displays a QR code
//! 3. Phone app scans the QR code, registers with relay server
//! 4. CLI stores the pairing key (encrypted) and device ID in `security.toml`
//! 5. Future approval requests go through the relay

use serde::{Deserialize, Serialize};

use crate::error::Error;
use crate::security_config::SecurityConfig;

/// A relay approval request sent to the paired device.
#[derive(Debug, Serialize)]
pub struct RelayRequest {
    /// Random challenge nonce (hex-encoded, 32 bytes).
    pub nonce: String,
    /// Unix timestamp of the request.
    pub timestamp: u64,
    /// Paired device identifier (routing / auditing on the relay).
    pub device_id: String,
    /// Binary requesting access.
    pub binary: String,
    /// Secret name.
    pub secret: String,
    /// Environment variable the secret will be injected as.
    pub env_var: String,
}

/// The response from the paired device.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RelayResponse {
    /// The original nonce, echoed back.
    pub nonce: String,
    /// Unix seconds from the relay (included in the HMAC when present).
    #[serde(default)]
    pub timestamp: Option<u64>,
    /// The user's decision.
    pub decision: RelayDecision,
    /// HMAC-SHA256 (hex) over the canonical signing payload (see `verify_relay_hmac`).
    pub signature: String,
}

/// Decision from the relay device.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RelayDecision {
    /// Approved.
    Allow,
    /// Denied.
    Deny,
    /// Timed out — user did not respond.
    Timeout,
}

/// Validate a relay base URL from config before it is passed to the HTTP client.
///
/// - Requires an `https://` URL with an explicit `scheme://` (not a bare host).
/// - Rejects `http://`, non-HTTPS schemes, embedded `user:pass@` credentials, and
///   any Unicode control character (prevents newline / `curl` argument injection).
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(())
}

/// Check whether relay auth is configured and required.
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()
}

/// Send an approval request to the relay server and wait for a response.
///
/// Returns `Ok(RelayDecision::Allow)` if the user approves,
/// `Err(Error::UserDenied)` if they deny or time out.
///
/// If the relay server is unreachable, returns an error — the caller
/// should fall back to local GUI approval.
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}"
        ))
    })?;

    // Generate challenge nonce
    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(),
    };

    // Serialize and POST to relay server
    let body = serde_json::to_string(&request)
        .map_err(|e| Error::CryptoFailure(format!("failed to serialize relay request: {e}")))?;

    // HTTP POST using a child process (no runtime deps).
    // Resolve `/usr/bin/curl` (etc.) like GUI binaries — never PATH-resolved `curl`.
    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}")))?;

            // Verify nonce matches
            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)
}

/// Maximum absolute skew between local Unix time and `RelayResponse.timestamp` for `Allow`
/// when using timestamp-bound HMAC.
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()
}

/// Generate a 32-byte random nonce as hex.
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
    })
}