envseal 0.3.9

Write-only secret vault with process-level access control — post-agent secret management
Documentation
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
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
//! Shared execution context — the single security control point.
//!
//! Every secret that leaves the vault flows through [`prepare_execution`].
//! It runs all the security checks (hostile-env detection, binary
//! resolution, TOCTOU pinning, hash verification, per-secret authorization,
//! audit logging, decryption) and returns a [`PreparedExecution`] that any
//! execution mode (exec-replace, pipe, supervised) consumes.

use std::path::PathBuf;

use zeroize::{Zeroize, Zeroizing};

use crate::error::Error;
use crate::guard;
use crate::gui::{self, Approval};
use crate::policy::{self, Policy};
use crate::vault::Vault;

/// Everything needed to spawn a child with secrets injected.
///
/// Built once via [`prepare_execution`], consumed by any execution mode.
/// Holds a file-descriptor keepalive for TOCTOU prevention; the caller
/// must keep the [`PreparedExecution`] alive across the spawn.
///
/// The pinned file descriptor is intentionally not directly accessible
/// to callers — its `Drop` is what closes the TOCTOU window. The Linux
/// Lockdown supervisor needs the raw fd as an argument to its
/// helper-mode invocation, and reaches it through the dedicated
/// `pinned_target_fd()` accessor that is only compiled on Linux
/// (`#[cfg(target_os = "linux")]`).
pub struct PreparedExecution {
    /// Resolved absolute path to the binary (used for display, audit, `arg0`).
    pub binary_path: String,
    /// Path passed to `Command::new` — `/proc/self/fd/N` on Linux for TOCTOU.
    pub exec_path: String,
    /// Arguments to pass to the binary (`command[1..]`).
    pub args: Vec<String>,
    /// Sanitized environment variables to set on the child.
    pub clean_env: std::collections::HashMap<String, String>,
    /// Decrypted secret env-var pairs (`(env_var_name, secret_value)`).
    pub env_pairs: Vec<(String, Zeroizing<String>)>,
    /// File-descriptor keepalive for TOCTOU prevention. Private — the
    /// `Drop` of the inner `File` is the side effect that matters; the
    /// rust compiler doesn't see field-only-for-drop as a use, hence
    /// the `dead_code` allow on platforms that don't read the fd.
    #[allow(dead_code)]
    pinned_file: Option<std::fs::File>,
}

impl PreparedExecution {
    /// On Linux, return the raw file descriptor of the pinned target
    /// binary so the Lockdown sandbox helper can be told to
    /// `execv("/proc/self/fd/N")` after finishing its mount setup.
    /// Returns `-1` (invalid fd) if no fd was pinned. Other platforms
    /// don't expose this — they pin via path on Windows, via
    /// `pre_exec` on macOS.
    #[cfg(target_os = "linux")]
    #[must_use]
    pub fn pinned_target_fd(&self) -> std::os::fd::RawFd {
        use std::os::fd::AsRawFd;
        self.pinned_file.as_ref().map_or(-1, AsRawFd::as_raw_fd)
    }
}

/// Run all the shared security checks and return everything needed for spawn.
///
/// This is the single security control point: env sanitization, binary
/// resolution, TOCTOU pinning, hash verification, per-secret authorization
/// (with GUI approval if needed), audit logging, and decryption all happen
/// here. The caller chooses how to spawn — exec-replace, pipe, or supervised.
///
/// # Errors
///
/// Returns an error if any security check fails (hostile env, missing
/// binary, hash mismatch, denied approval, decryption failure).
#[allow(clippy::too_many_lines)]
pub fn prepare_execution(
    vault: &Vault,
    mappings: &[(&str, &str)],
    command: &[String],
) -> Result<PreparedExecution, Error> {
    // STEP 1: Load security config
    let mut sec_config =
        crate::security_config::load_config(vault.root(), vault.master_key_bytes())?;

    // STEP 2: GUARD — environment-injection policy (signal taxonomy)
    guard::enforce_env_policy(&sec_config)?;

    // STEP 2b: GUI-environment signals (input injectors, screen
    // recorders, remote desktop, X11) are evaluated on EVERY injection,
    // even when AllowAlways would otherwise skip the popup. Pre-approved
    // rules must not bypass the physical-presence / automation-detection
    // taxonomy — an attacker who obtained shell access while the user is
    // away should not be able to auto-approve via a pre-existing rule.
    let gui_signals = guard::assess_gui_signals(&guard::DetectorContext::ambient());
    guard::emit_signals_inline(gui_signals, &sec_config)?;

    if command.is_empty() {
        return Err(Error::BinaryResolution(
            "no command specified after --".to_string(),
        ));
    }

    // STEP 2b: Validate each requested env var name. Done before resolution
    // so a malformed/blocklisted name is rejected before any I/O.
    for &(_, env_var) in mappings {
        super::inject::validate_env_var_name(env_var)?;
    }

    // STEP 2b'.0: Hard-block the CTF flag from any injection path.
    //
    // The flag exists for ONE legitimate purpose: hash-comparison
    // via `envseal ctf verify <guess>`. There is no scenario in
    // which `inject` / `pipe` / `run` should hand the flag bytes to
    // a child process — even a "trusted" one. The whole CTF claim
    // ("nobody gets it out, not human, not AI, not both together")
    // collapses if a single Allow click on a python script defeats
    // it. Refusing here is not a policy decision the operator can
    // override; the flag is structurally non-injectable.
    //
    // We log this as a `Severity::Critical` signal so the audit
    // trail records every attempt — the CTF status page surfaces
    // attempt counts as part of the "active challenge" view.
    for &(secret_name, _) in mappings {
        if secret_name.eq_ignore_ascii_case("ctf-flag") {
            let _ = crate::audit::log_required(&crate::audit::AuditEvent::SignalRecorded {
                tier: format!("{:?}", sec_config.tier),
                classification: format!(
                    "critical [ctf.flag.injection_attempt] refused to inject 'ctf-flag' \
                     into '{}' — flag is structurally non-injectable",
                    command.first().map_or("<no-command>", String::as_str)
                ),
            });
            return Err(Error::EnvironmentCompromised(
                "ctf-flag is non-injectable by design. The CTF flag exists only \
                 for `envseal ctf verify` hash comparison; envseal refuses to \
                 hand its plaintext to any child process. Run `envseal ctf reset` \
                 to clear the challenge."
                    .to_string(),
            ));
        }
    }

    // STEP 2c: Preflight — all referenced secrets must exist in the
    // vault before we ask the user to approve anything. The previous
    // form discovered missing secrets only during phase-2 decrypt,
    // AFTER the operator clicked through approvals + a challenge
    // gate for the OTHER secrets. With several secrets in a
    // `.envseal` mapping, hitting one stale/revoked entry made every
    // click on the others wasted (and pre-the-policy-save fix, even
    // re-prompted on the next run). Surface missing secrets up front
    // with the env-var name so the user knows exactly which mapping
    // line to fix.
    let missing: Vec<String> = mappings
        .iter()
        .filter(|(name, _)| !vault.has_secret(name))
        .map(|(name, env_var)| format!("{env_var}={name}"))
        .collect();
    if !missing.is_empty() {
        return Err(Error::SecretNotFound(format!(
            "the following .envseal mapping(s) reference secrets that aren't in the vault: \
             {} — store them with `envseal store <name>` or remove the line(s) from .envseal",
            missing.join(", ")
        )));
    }

    // STEP 3: Resolve binary
    let binary_path = policy::resolve_binary(&command[0])?;

    // STEP 4: Load AEAD-sealed policy. `load_sealed` returns
    // `Policy::default()` when neither sealed nor legacy file is
    // present, so a fresh vault still works without policy.
    let policy_path = vault.policy_path();
    let mut policy = Policy::load_sealed(&policy_path, vault.master_key_bytes())?;
    if policy.generation < sec_config.policy_generation {
        return Err(Error::EnvironmentCompromised(format!(
            "policy rollback detected: loaded generation {} is older than expected {}",
            policy.generation, sec_config.policy_generation
        )));
    }

    // STEP 5: TOCTOU pin — open the binary now, exec the fd later
    let binary_path_buf = PathBuf::from(&binary_path);
    #[allow(unused_mut, unused_assignments)]
    let mut exec_path = binary_path.clone();
    #[allow(unused_mut, unused_assignments)]
    let mut kept_alive_file: Option<std::fs::File> = None;

    #[cfg(target_os = "linux")]
    {
        use std::os::unix::fs::OpenOptionsExt;
        use std::os::unix::io::AsRawFd;
        let file = std::fs::OpenOptions::new()
            .read(true)
            .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
            .open(&binary_path_buf)
            .map_err(|e| {
                Error::BinaryResolution(format!("failed to open binary for TOCTOU prevention: {e}"))
            })?;
        exec_path = format!("/proc/self/fd/{}", file.as_raw_fd());
        kept_alive_file = Some(file);
    }

    #[cfg(all(unix, not(target_os = "linux")))]
    {
        use std::os::unix::fs::OpenOptionsExt;
        use std::os::unix::io::AsRawFd;
        let file = std::fs::OpenOptions::new()
            .read(true)
            .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
            .open(&binary_path_buf)
            .map_err(|e| {
                Error::BinaryResolution(format!("failed to open binary for TOCTOU prevention: {e}"))
            })?;
        exec_path = format!("/dev/fd/{}", file.as_raw_fd());
        kept_alive_file = Some(file);
    }

    #[cfg(windows)]
    {
        // On Windows we can't exec via a fd path, but we CAN keep a
        // restrictive-share handle open during spawn to prevent the file
        // from being deleted or replaced between hash verification and
        // CreateProcessW.
        use std::os::windows::ffi::OsStrExt;
        use std::os::windows::io::FromRawHandle;
        use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
        use windows_sys::Win32::Storage::FileSystem::{
            CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, OPEN_EXISTING,
        };
        let wide: Vec<u16> = binary_path_buf
            .as_os_str()
            .encode_wide()
            .chain(std::iter::once(0))
            .collect();
        let handle = unsafe {
            CreateFileW(
                wide.as_ptr(),
                0x8000_0000,     // GENERIC_READ
                FILE_SHARE_READ, // deny write/delete while we hold the handle
                std::ptr::null_mut(),
                OPEN_EXISTING,
                FILE_ATTRIBUTE_NORMAL,
                std::ptr::null_mut(),
            )
        };
        if handle == INVALID_HANDLE_VALUE {
            return Err(Error::BinaryResolution(format!(
                "failed to open binary for TOCTOU prevention: {}",
                std::io::Error::last_os_error()
            )));
        }
        let file = unsafe { std::fs::File::from_raw_handle(handle.cast::<std::ffi::c_void>()) };
        exec_path.clone_from(&binary_path);
        kept_alive_file = Some(file);
    }

    // STEP 6: Verify binary hash if stored in policy
    if let Some(stored_hash) = policy.binary_hash(&binary_path) {
        if let Some(ref mut file) = kept_alive_file {
            guard::verify_file_hash(file, &stored_hash, &binary_path_buf)?;
        } else {
            guard::verify_binary_hash(&binary_path_buf, &stored_hash)?;
        }
    }

    // STEP 7: Per-secret authorization + decryption.
    //
    // Authorization is argv-bound: the rule we look up (and the rule we
    // store on AllowAlways) is keyed by `(binary, secret, fingerprint)`,
    // where the fingerprint is computed from `command[1..]`. An approval
    // for `wrangler deploy` therefore does NOT auto-allow `wrangler
    // --shell evil.sh` on the next run.
    let fingerprint_str = super::inject::command_fingerprint(command);
    let fingerprint_for_lookup = if fingerprint_str.is_empty() {
        None
    } else {
        Some(fingerprint_str.as_str())
    };

    // Pre-compute the binary hash so every authorization check can
    // verify the binary hasn't been replaced since approval.
    let mut current_hash: Option<String> = None;

    // Two-phase split: collect approvals first, persist policy
    // BEFORE any decrypt runs, then decrypt + inject. The previous
    // single-loop form lost every Allow Always click if any secret
    // failed to decrypt — operator clicks "Allow Always" three times
    // for three secrets, the fourth has been revoked, decrypt fails,
    // function returns, the three Allow Always rules are vaporized,
    // next invocation re-prompts for all of them. Splitting the
    // phases means an Allow Always is durable the moment the user
    // clicks it, regardless of what fails downstream.
    let mut policy_dirty = false;
    for &(secret_name, env_var) in mappings {
        if current_hash.is_none() {
            let hash = if let Some(ref mut file) = kept_alive_file {
                guard::hash_open_file(file)?
            } else {
                guard::hash_binary(std::path::Path::new(&binary_path))?
            };
            current_hash = Some(hash);
        }
        if policy.is_authorized_with_hash_and_args(
            &binary_path,
            secret_name,
            fingerprint_for_lookup,
            current_hash.as_deref(),
        ) {
            continue;
        }
        let approval =
            gui::request_approval(&binary_path, command, secret_name, env_var, &sec_config)?;
        match approval {
            Approval::AllowOnce => {}
            Approval::AllowAlways => {
                let hash = current_hash.as_deref().unwrap_or("");
                // Apply tier-driven default expiry, then clamp to
                // the configured max. CTF mode sets max=600s so
                // every CTF-window approval is short-lived even if
                // the operator clicks "Allow Forever". Standard
                // tier with no expiry config falls through to a
                // forever rule (current default behaviour).
                let now = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .map_or(0, |d| d.as_secs());
                let default_expiry = sec_config.default_rule_expiry_secs;
                let max_expiry = sec_config.max_rule_expiry_secs;
                let effective_expiry = match (default_expiry, max_expiry) {
                    (None, None) => None,
                    (Some(d), None) => Some(d),
                    (None, Some(m)) => Some(m),
                    (Some(d), Some(m)) => Some(d.min(m)),
                };
                if let Some(secs) = effective_expiry {
                    policy.allow_key_with_expiry(
                        &binary_path,
                        secret_name,
                        hash,
                        fingerprint_for_lookup.map(str::to_string),
                        now.saturating_add(secs),
                    );
                } else {
                    policy.allow_key_with_hash_and_args(
                        &binary_path,
                        secret_name,
                        hash,
                        fingerprint_for_lookup.map(str::to_string),
                    );
                }
                policy_dirty = true;
            }
            Approval::Deny => {
                // Persist any Allow Always rules approved earlier in
                // this batch even if a later secret was denied —
                // throwing them away on Deny would surprise the user
                // ("I just clicked Allow Always twice, why am I being
                // re-prompted?").
                if policy_dirty {
                    policy.save_sealed(&policy_path, vault.master_key_bytes())?;
                    sec_config.policy_generation = policy.generation;
                    crate::security_config::save_config(
                        vault.root(),
                        &sec_config,
                        vault.master_key_bytes(),
                    )?;
                }
                return Err(Error::UserDenied);
            }
        }
    }
    if policy_dirty {
        policy.save_sealed(&policy_path, vault.master_key_bytes())?;
        sec_config.policy_generation = policy.generation;
        crate::security_config::save_config(vault.root(), &sec_config, vault.master_key_bytes())?;
    }

    // Phase 2: decrypt + inject. By this point every Allow Always is
    // already on disk, so a decrypt failure here is recoverable —
    // the user fixes the missing secret and re-runs without
    // re-clicking anything.
    let mut env_pairs: Vec<(String, Zeroizing<String>)> = Vec::with_capacity(mappings.len());
    for &(secret_name, env_var) in mappings {
        let mut plaintext = vault.decrypt(secret_name)?;
        let s = match String::from_utf8(std::mem::take(&mut *plaintext)) {
            Ok(s) => s,
            Err(e) => {
                let mut vec = e.into_bytes();
                vec.zeroize();
                return Err(Error::CryptoFailure(format!(
                    "secret '{secret_name}' is not valid UTF-8"
                )));
            }
        };
        let value = Zeroizing::new(s);
        env_pairs.push((env_var.to_string(), value));
    }

    // STEP 8: Audit log every secret access
    crate::audit::log_required(&crate::audit::AuditEvent::SecretAccessed {
        binary: binary_path.clone(),
        secret: mappings
            .iter()
            .map(|(s, _)| (*s).to_string())
            .collect::<Vec<_>>()
            .join(","),
    })?;

    // STEP 9: Build sanitized environment for the child
    let clean_env = guard::sanitized_env();

    Ok(PreparedExecution {
        binary_path,
        exec_path,
        args: command[1..].to_vec(),
        clean_env,
        env_pairs,
        pinned_file: kept_alive_file,
    })
}

/// Hardening that runs in the child between `fork()` and `exec()`.
///
/// **Must only use async-signal-safe operations** — no allocation, no
/// `std::fs`, no panic, no `eprintln`. Three syscalls:
///
/// - `prctl(PR_SET_DUMPABLE, 0)` — block ptrace and `/proc/<pid>/mem`.
/// - `setrlimit(RLIMIT_CORE, 0)` — defeat core dumps.
/// - `prctl(PR_SET_NO_NEW_PRIVS, 1)` — prevent suid escalation in child.
///
/// # Errors
///
/// Returns the OS error if any of the three syscalls fail.
#[cfg(target_os = "linux")]
pub fn harden_child_process_inner() -> std::io::Result<()> {
    unsafe {
        if libc::prctl(libc::PR_SET_DUMPABLE, 0) < 0 {
            return Err(std::io::Error::last_os_error());
        }
        let rlimit = libc::rlimit {
            rlim_cur: 0,
            rlim_max: 0,
        };
        if libc::setrlimit(libc::RLIMIT_CORE, &rlimit) < 0 {
            return Err(std::io::Error::last_os_error());
        }
        if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0 {
            return Err(std::io::Error::last_os_error());
        }
    }
    Ok(())
}

/// No-op child hardening on non-Linux platforms.
///
/// # Errors
/// Never errors on non-Linux platforms.
#[cfg(not(target_os = "linux"))]
pub fn harden_child_process_inner() -> std::io::Result<()> {
    Ok(())
}