envseal 0.3.13

Write-only secret vault with process-level access control — post-agent secret management
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! 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> {
    // 4 KiB is comfortably larger than any sane URL (RFC 7230
    // doesn't set a hard limit but most servers reject > 8 KiB).
    // We cap on the lower side so a hostile config can't push a
    // multi-MB string into curl's argv vector.
    const MAX_URL_BYTES: usize = 4 * 1024;
    if url.len() > MAX_URL_BYTES {
        return Err(Error::CryptoFailure(format!(
            "relay endpoint URL is {} bytes, exceeds {MAX_URL_BYTES} cap",
            url.len()
        )));
    }
    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",
            // Cap the response body so a hostile relay server (or
            // a MITM redirecting curl to a multi-GB blob) cannot
            // pin envseal's RAM. RelayResponse is ~200 bytes; 64
            // KiB is two orders of magnitude beyond that.
            "--max-filesize",
            "65536",
            "-X",
            "POST",
            "-H",
            "Content-Type: application/json",
            "-d",
            &body,
            &format!("{endpoint}/v1/approve"),
        ])
        .output();

    match result {
        Ok(output) if output.status.success() => {
            // Defense in depth: even with --max-filesize, cap our
            // own read so curl can't blow past it on a server that
            // streams without a Content-Length header.
            const MAX_RESPONSE_BYTES: usize = 256 * 1024;
            let stdout_bytes = if output.stdout.len() > MAX_RESPONSE_BYTES {
                &output.stdout[..MAX_RESPONSE_BYTES]
            } else {
                &output.stdout[..]
            };
            let response_text = String::from_utf8_lossy(stdout_bytes);
            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>;
    // Pairing key is the secret that authorizes Allow decisions —
    // hold it in Zeroizing so the heap allocation is wiped on drop
    // even on the error path (signature mismatch, freshness window
    // exceeded, etc).
    let key = zeroize::Zeroizing::new(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> {
    /// Pairing keys + signatures are 32-byte values — 64 hex chars
    /// in their plaintext form. 4 KiB is two orders of magnitude
    /// past any legitimate input and stops a hostile config or
    /// relay response from forcing a multi-MB allocation in the
    /// hex parser.
    const MAX_RELAY_HEX_LEN: usize = 4 * 1024;
    if s.len() > MAX_RELAY_HEX_LEN {
        return Err(Error::CryptoFailure(format!(
            "hex string too long: {} bytes exceeds {MAX_RELAY_HEX_LEN}",
            s.len()
        )));
    }
    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
    })
}