envseal 0.3.12

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Windows GUI dialog backend — drives `PowerShell` plus
//! `System.Windows.Forms` to render the passphrase, secret-value,
//! approval, TOTP, and preexec prompts. All five entry points are
//! reached through the [`super`] module; nothing in this file is
//! meant to be called directly from outside `gui::`.

#![cfg(target_os = "windows")]

use super::Approval;
use crate::error::Error;
use std::process::Command;
use zeroize::Zeroizing;

/// Escape a string for safe interpolation inside a `PowerShell`
/// single-quoted literal. Single quotes are doubled per `PowerShell`'s
/// quoting rules — there is no other character that needs escaping
/// inside single-quoted strings.
pub(crate) fn escape_ps(s: &str) -> String {
    s.replace('\'', "''")
}

/// Common `WinForms` boilerplate that every envseal dialog must apply
/// to actually appear *in front of* the user's active window.
///
/// Without `TopMost = $true`, `StartPosition = 'CenterScreen'`,
/// `Activate()` and `BringToFront()`, dialogs spawned from a deeply
/// nested process tree (CLI → `ops::*` → `gui::request_passphrase` →
/// powershell.exe) frequently render at default position
/// (top-left of screen 0) AND behind the active window — invisible
/// to the user, who then sees the calling tool hang.
///
/// Embedded in every dialog script via string interpolation —
/// kept as a single source of truth so the next dialog we add can
/// just splice this in.
const FOREGROUND_SETUP: &str =
    "$form.StartPosition = 'CenterScreen'; $form.TopMost = $true; $form.ShowInTaskbar = $true;";

/// Best-effort post-show calls — Windows tends to demote `TopMost`
/// the instant the user clicks elsewhere, so re-asserting via
/// `Activate()` and `BringToFront()` keeps the dialog reachable.
const FOREGROUND_FOCUS: &str = "$form.Add_Shown({ $form.Activate(); $form.BringToFront(); });";

/// Render the passphrase prompt.
///
/// `is_new = true` creates a vault: shows two masked entry fields and
/// validates they match before returning. On mismatch the dialog
/// stays open with an inline error so the operator can retype — there
/// is NEVER a path where a typo silently becomes the master
/// passphrase, because doing so would brick the vault on the next
/// unlock.
///
/// `is_new = false` unlocks an existing vault: a single masked field.
///
/// Both flows include a "Show passphrase" toggle. Shoulder-surfing
/// risk is the operator's call; the alternative — typing into a
/// silently-masked field with no echo and no length feedback — is
/// strictly worse because the operator can't even tell whether their
/// keystroke registered.
pub(crate) fn windows_passphrase(
    is_new: bool,
    prev_error: Option<&str>,
) -> Result<Zeroizing<String>, Error> {
    if is_new {
        windows_passphrase_create(prev_error)
    } else {
        windows_passphrase_unlock(prev_error)
    }
}

fn windows_passphrase_create(prev_error: Option<&str>) -> Result<Zeroizing<String>, Error> {
    // Two-field create dialog. The OK handler validates length and
    // exact-case match BEFORE closing the form, so the only paths
    // out are (a) a valid, confirmed passphrase, or (b) Cancel. No
    // silent typo path can become the master passphrase.
    let init_err = prev_error.map(escape_ps).unwrap_or_default();
    let script = format!(
        r"Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $form = New-Object System.Windows.Forms.Form; $form.Text = 'envseal — Create Vault Passphrase'; $form.Size = New-Object System.Drawing.Size(440,290); {FOREGROUND_SETUP} {FOREGROUND_FOCUS} $lbl1 = New-Object System.Windows.Forms.Label; $lbl1.Text = 'Create a vault passphrase (min 8 chars):'; $lbl1.AutoSize = $true; $lbl1.Location = New-Object System.Drawing.Point(12,12); $form.Controls.Add($lbl1); $tb1 = New-Object System.Windows.Forms.TextBox; $tb1.PasswordChar = '*'; $tb1.Location = New-Object System.Drawing.Point(12,40); $tb1.Size = New-Object System.Drawing.Size(400,22); $form.Controls.Add($tb1); $lbl2 = New-Object System.Windows.Forms.Label; $lbl2.Text = 'Confirm passphrase:'; $lbl2.AutoSize = $true; $lbl2.Location = New-Object System.Drawing.Point(12,72); $form.Controls.Add($lbl2); $tb2 = New-Object System.Windows.Forms.TextBox; $tb2.PasswordChar = '*'; $tb2.Location = New-Object System.Drawing.Point(12,100); $tb2.Size = New-Object System.Drawing.Size(400,22); $form.Controls.Add($tb2); $cb = New-Object System.Windows.Forms.CheckBox; $cb.Text = 'Show passphrase'; $cb.AutoSize = $true; $cb.Location = New-Object System.Drawing.Point(12,130); $cb.Add_CheckedChanged({{ if ($cb.Checked) {{ $tb1.PasswordChar = [char]0; $tb2.PasswordChar = [char]0 }} else {{ $tb1.PasswordChar = '*'; $tb2.PasswordChar = '*' }} }}); $form.Controls.Add($cb); $lblErr = New-Object System.Windows.Forms.Label; $lblErr.Text = '{init_err}'; $lblErr.AutoSize = $false; $lblErr.Size = New-Object System.Drawing.Size(400,38); $lblErr.Location = New-Object System.Drawing.Point(12,158); $lblErr.ForeColor = [System.Drawing.Color]::FromArgb(190,40,40); $form.Controls.Add($lblErr); $btnOk = New-Object System.Windows.Forms.Button; $btnOk.Text = 'Create'; $btnOk.Size = New-Object System.Drawing.Size(110,30); $btnOk.Location = New-Object System.Drawing.Point(12,210); $btnOk.Add_Click({{ if ($tb1.Text.Length -lt 8) {{ $lblErr.Text = 'Passphrase must be at least 8 characters.'; $tb1.Focus(); return }} if ($tb1.Text -cne $tb2.Text) {{ $lblErr.Text = 'Passphrases did not match. Try again.'; $tb2.Text = ''; $tb2.Focus(); return }} $form.DialogResult = [System.Windows.Forms.DialogResult]::OK; $form.Close() }}); $form.Controls.Add($btnOk); $btnCancel = New-Object System.Windows.Forms.Button; $btnCancel.Text = 'Cancel'; $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel; $btnCancel.Size = New-Object System.Drawing.Size(110,30); $btnCancel.Location = New-Object System.Drawing.Point(132,210); $form.Controls.Add($btnCancel); $form.AcceptButton = $btnOk; $form.CancelButton = $btnCancel; $result = $form.ShowDialog(); if ($result -eq [System.Windows.Forms.DialogResult]::OK) {{ Write-Output $tb1.Text }}"
    );
    run_passphrase_script(&script)
}

fn windows_passphrase_unlock(prev_error: Option<&str>) -> Result<Zeroizing<String>, Error> {
    // The error label is populated when the previous unlock attempt
    // failed — so the operator sees "Incorrect passphrase, try again."
    // in the SAME dialog and can correct without re-running the
    // command.
    let init_err = prev_error.map(escape_ps).unwrap_or_default();
    let script = format!(
        r"Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $form = New-Object System.Windows.Forms.Form; $form.Text = 'envseal — Unlock Vault'; $form.Size = New-Object System.Drawing.Size(420,210); {FOREGROUND_SETUP} {FOREGROUND_FOCUS} $lbl = New-Object System.Windows.Forms.Label; $lbl.Text = 'Enter vault passphrase:'; $lbl.AutoSize = $true; $lbl.Location = New-Object System.Drawing.Point(12,12); $form.Controls.Add($lbl); $tb = New-Object System.Windows.Forms.TextBox; $tb.PasswordChar = '*'; $tb.Location = New-Object System.Drawing.Point(12,40); $tb.Size = New-Object System.Drawing.Size(380,22); $form.Controls.Add($tb); $cb = New-Object System.Windows.Forms.CheckBox; $cb.Text = 'Show passphrase'; $cb.AutoSize = $true; $cb.Location = New-Object System.Drawing.Point(12,70); $cb.Add_CheckedChanged({{ if ($cb.Checked) {{ $tb.PasswordChar = [char]0 }} else {{ $tb.PasswordChar = '*' }} }}); $form.Controls.Add($cb); $lblErr = New-Object System.Windows.Forms.Label; $lblErr.Text = '{init_err}'; $lblErr.AutoSize = $false; $lblErr.Size = New-Object System.Drawing.Size(380,30); $lblErr.Location = New-Object System.Drawing.Point(12,98); $lblErr.ForeColor = [System.Drawing.Color]::FromArgb(190,40,40); $form.Controls.Add($lblErr); $btnOk = New-Object System.Windows.Forms.Button; $btnOk.Text = 'Unlock'; $btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK; $btnOk.Size = New-Object System.Drawing.Size(110,30); $btnOk.Location = New-Object System.Drawing.Point(12,134); $form.Controls.Add($btnOk); $btnCancel = New-Object System.Windows.Forms.Button; $btnCancel.Text = 'Cancel'; $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel; $btnCancel.Size = New-Object System.Drawing.Size(110,30); $btnCancel.Location = New-Object System.Drawing.Point(132,134); $form.Controls.Add($btnCancel); $form.AcceptButton = $btnOk; $form.CancelButton = $btnCancel; $result = $form.ShowDialog(); if ($result -eq [System.Windows.Forms.DialogResult]::OK) {{ Write-Output $tb.Text }}"
    );
    run_passphrase_script(&script)
}

fn run_passphrase_script(script: &str) -> Result<Zeroizing<String>, Error> {
    let result = Command::new("powershell")
        .args(["-NoProfile", "-Command", script])
        .output();
    match result {
        Ok(output) if output.status.success() => {
            // The dialog only emits stdout when DialogResult::OK fires
            // AND the OK handler accepted the input — so an empty
            // stdout here is a deliberate Cancel, not a typo path.
            let passphrase = String::from_utf8_lossy(&output.stdout)
                .trim_end_matches(&['\n', '\r'][..])
                .to_string();
            if passphrase.is_empty() {
                Err(Error::UserDenied)
            } else {
                Ok(Zeroizing::new(passphrase))
            }
        }
        _ => Err(Error::UserDenied),
    }
}

pub(crate) fn windows_secret_value(
    key_name: &str,
    description: &str,
) -> Result<Zeroizing<String>, Error> {
    let label_text = format!(
        "Paste value for secret '{}':\\n{}",
        escape_ps(key_name),
        escape_ps(description)
    );
    // Custom Form rather than `[Microsoft.VisualBasic.Interaction]::InputBox`
    // because InputBox has no TopMost / Activate API — it ships at
    // default position behind the foreground window.
    let script = format!(
        r"Add-Type -AssemblyName System.Windows.Forms; $form = New-Object System.Windows.Forms.Form; $form.Text = 'envseal — Store Secret'; $form.Size = New-Object System.Drawing.Size(440,180); {FOREGROUND_SETUP} {FOREGROUND_FOCUS} $lbl = New-Object System.Windows.Forms.Label; $lbl.Text = '{label_text}'; $lbl.AutoSize = $false; $lbl.Size = New-Object System.Drawing.Size(410,60); $lbl.Location = New-Object System.Drawing.Point(10,10); $form.Controls.Add($lbl); $tb = New-Object System.Windows.Forms.TextBox; $tb.PasswordChar = '*'; $tb.Location = New-Object System.Drawing.Point(10,80); $tb.Size = New-Object System.Drawing.Size(400,20); $form.Controls.Add($tb); $btn = New-Object System.Windows.Forms.Button; $btn.Text = 'OK'; $btn.DialogResult = [System.Windows.Forms.DialogResult]::OK; $btn.Location = New-Object System.Drawing.Point(10,110); $form.Controls.Add($btn); $form.AcceptButton = $btn; $result = $form.ShowDialog(); if ($result -eq [System.Windows.Forms.DialogResult]::OK) {{ Write-Output $tb.Text }}"
    );
    let result = Command::new("powershell")
        .args(["-NoProfile", "-Command", &script])
        .output();
    match result {
        Ok(output) if output.status.success() => {
            let secret = String::from_utf8_lossy(&output.stdout).trim().to_string();
            if secret.is_empty() {
                Err(Error::UserDenied)
            } else {
                Ok(Zeroizing::new(secret))
            }
        }
        _ => Err(Error::UserDenied),
    }
}

pub(crate) fn windows_popup(
    binary_path: &str,
    command: &str,
    secret_name: &str,
    env_var: &str,
    warnings: &str,
) -> Result<Approval, Error> {
    let text = format!("Approval needed to inject secret.\n\nBinary: {binary_path}\nCommand: {command}\nSecret: {secret_name}\nEnv Var: {env_var}\n{warnings}");
    let script = format!(
        r"Add-Type -AssemblyName System.Windows.Forms; $form = New-Object System.Windows.Forms.Form; $form.Text = 'envseal — Inject Secret'; $form.Size = New-Object System.Drawing.Size(400,250); {FOREGROUND_SETUP} {FOREGROUND_FOCUS} $lbl = New-Object System.Windows.Forms.Label; $lbl.Text = '{}'; $lbl.AutoSize = $true; $lbl.Location = New-Object System.Drawing.Point(10,10); $form.Controls.Add($lbl); $btnAllow = New-Object System.Windows.Forms.Button; $btnAllow.Text = 'Allow Once'; $btnAllow.DialogResult = [System.Windows.Forms.DialogResult]::OK; $btnAllow.Location = New-Object System.Drawing.Point(10,180); $form.Controls.Add($btnAllow); $btnAlways = New-Object System.Windows.Forms.Button; $btnAlways.Text = 'Allow Always'; $btnAlways.DialogResult = [System.Windows.Forms.DialogResult]::Yes; $btnAlways.Location = New-Object System.Drawing.Point(110,180); $form.Controls.Add($btnAlways); $btnDeny = New-Object System.Windows.Forms.Button; $btnDeny.Text = 'Deny'; $btnDeny.DialogResult = [System.Windows.Forms.DialogResult]::Cancel; $btnDeny.Location = New-Object System.Drawing.Point(210,180); $form.Controls.Add($btnDeny); $form.AcceptButton = $btnAllow; $form.CancelButton = $btnDeny; $result = $form.ShowDialog(); if ($result -eq [System.Windows.Forms.DialogResult]::OK) {{ Write-Output 'AllowOnce' }} elseif ($result -eq [System.Windows.Forms.DialogResult]::Yes) {{ Write-Output 'AllowAlways' }} else {{ Write-Output 'Deny' }}",
        escape_ps(&text)
    );
    let result = Command::new("powershell")
        .args(["-NoProfile", "-Command", &script])
        .output();
    match result {
        Ok(output) if output.status.success() => {
            let out = String::from_utf8_lossy(&output.stdout);
            if out.contains("AllowOnce") {
                return Ok(Approval::AllowOnce);
            }
            if out.contains("AllowAlways") {
                return Ok(Approval::AllowAlways);
            }
            Err(Error::UserDenied)
        }
        _ => Err(Error::UserDenied),
    }
}

pub(crate) fn windows_preexec_prompt(message: &str) -> Result<super::PreexecChoice, Error> {
    let script = format!(
        r"Add-Type -AssemblyName System.Windows.Forms; $form = New-Object System.Windows.Forms.Form; $form.Text = 'envseal — Migrate API key'; $form.Size = New-Object System.Drawing.Size(520,260); $form.StartPosition = 'CenterScreen'; $form.TopMost = $true; $lbl = New-Object System.Windows.Forms.Label; $lbl.Text = '{}'; $lbl.AutoSize = $false; $lbl.Size = New-Object System.Drawing.Size(490,150); $lbl.Location = New-Object System.Drawing.Point(10,10); $form.Controls.Add($lbl); $btnStore = New-Object System.Windows.Forms.Button; $btnStore.Text = 'Store in vault'; $btnStore.DialogResult = [System.Windows.Forms.DialogResult]::OK; $btnStore.Size = New-Object System.Drawing.Size(140,30); $btnStore.Location = New-Object System.Drawing.Point(10,180); $form.Controls.Add($btnStore); $btnSkip = New-Object System.Windows.Forms.Button; $btnSkip.Text = 'Not now'; $btnSkip.DialogResult = [System.Windows.Forms.DialogResult]::Cancel; $btnSkip.Size = New-Object System.Drawing.Size(140,30); $btnSkip.Location = New-Object System.Drawing.Point(160,180); $form.Controls.Add($btnSkip); $btnNever = New-Object System.Windows.Forms.Button; $btnNever.Text = 'Don''t ask again'; $btnNever.DialogResult = [System.Windows.Forms.DialogResult]::Ignore; $btnNever.Size = New-Object System.Drawing.Size(140,30); $btnNever.Location = New-Object System.Drawing.Point(310,180); $form.Controls.Add($btnNever); $form.AcceptButton = $btnStore; $form.CancelButton = $btnSkip; $result = $form.ShowDialog(); if ($result -eq [System.Windows.Forms.DialogResult]::OK) {{ Write-Output 'Store' }} elseif ($result -eq [System.Windows.Forms.DialogResult]::Ignore) {{ Write-Output 'Never' }} else {{ Write-Output 'Skip' }}",
        escape_ps(message)
    );
    let output = Command::new("powershell")
        .args(["-NoProfile", "-Command", &script])
        .output()
        .map_err(|e| Error::CryptoFailure(format!("powershell failed: {e}")))?;
    if !output.status.success() {
        return Ok(super::PreexecChoice::Skip);
    }
    let out = String::from_utf8_lossy(&output.stdout);
    if out.contains("Store") {
        Ok(super::PreexecChoice::Store)
    } else if out.contains("Never") {
        Ok(super::PreexecChoice::DontAskAgain)
    } else {
        Ok(super::PreexecChoice::Skip)
    }
}

pub(crate) fn windows_fido2_pin_entry(
    retries_left: u32,
    attempt: u32,
) -> Result<Zeroizing<String>, Error> {
    let label_text = format!(
        "Enter the PIN for your security key (attempt {attempt}; {retries_left} authenticator retries remaining)"
    );
    // Form with PasswordChar set so the PIN doesn't echo on screen.
    // ScreenReader recorders, screen captures, and shoulder-surfers
    // are the threat model — for the same reasons we mask the
    // passphrase, we mask the PIN.
    let script = format!(
        r"Add-Type -AssemblyName System.Windows.Forms; $form = New-Object System.Windows.Forms.Form; $form.Text = 'envseal — FIDO2 PIN'; $form.Size = New-Object System.Drawing.Size(420,170); {FOREGROUND_SETUP} {FOREGROUND_FOCUS} $lbl = New-Object System.Windows.Forms.Label; $lbl.Text = '{label_text}'; $lbl.AutoSize = $true; $lbl.Location = New-Object System.Drawing.Point(10,10); $form.Controls.Add($lbl); $tb = New-Object System.Windows.Forms.TextBox; $tb.Location = New-Object System.Drawing.Point(10,55); $tb.Size = New-Object System.Drawing.Size(380,20); $tb.PasswordChar = '*'; $form.Controls.Add($tb); $btn = New-Object System.Windows.Forms.Button; $btn.Text = 'Continue'; $btn.DialogResult = [System.Windows.Forms.DialogResult]::OK; $btn.Location = New-Object System.Drawing.Point(10,90); $form.Controls.Add($btn); $form.AcceptButton = $btn; $result = $form.ShowDialog(); if ($result -eq [System.Windows.Forms.DialogResult]::OK) {{ Write-Output $tb.Text }}"
    );
    let output = Command::new("powershell")
        .args(["-NoProfile", "-Command", &script])
        .output()
        .map_err(|e| Error::CryptoFailure(format!("powershell failed: {e}")))?;
    if !output.status.success() {
        return Err(Error::UserDenied);
    }
    let pin = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if pin.is_empty() {
        return Err(Error::UserDenied);
    }
    Ok(Zeroizing::new(pin))
}

pub(crate) fn windows_totp_entry(attempt: u32) -> Result<String, Error> {
    let label_text = format!("Enter your 6-digit authenticator code (attempt {attempt}/3)");
    // Custom Form rather than InputBox so we can force foreground —
    // a TOTP prompt that appears behind another window is the same
    // bug pattern as the passphrase prompt one (user thinks the
    // tool hung; tool eventually times out).
    let script = format!(
        r"Add-Type -AssemblyName System.Windows.Forms; $form = New-Object System.Windows.Forms.Form; $form.Text = 'envseal — TOTP Verification'; $form.Size = New-Object System.Drawing.Size(380,160); {FOREGROUND_SETUP} {FOREGROUND_FOCUS} $lbl = New-Object System.Windows.Forms.Label; $lbl.Text = '{label_text}'; $lbl.AutoSize = $true; $lbl.Location = New-Object System.Drawing.Point(10,10); $form.Controls.Add($lbl); $tb = New-Object System.Windows.Forms.TextBox; $tb.Location = New-Object System.Drawing.Point(10,50); $tb.Size = New-Object System.Drawing.Size(340,20); $tb.MaxLength = 6; $form.Controls.Add($tb); $btn = New-Object System.Windows.Forms.Button; $btn.Text = 'Verify'; $btn.DialogResult = [System.Windows.Forms.DialogResult]::OK; $btn.Location = New-Object System.Drawing.Point(10,80); $form.Controls.Add($btn); $form.AcceptButton = $btn; $result = $form.ShowDialog(); if ($result -eq [System.Windows.Forms.DialogResult]::OK) {{ Write-Output $tb.Text }}"
    );
    let output = Command::new("powershell")
        .args(["-NoProfile", "-Command", &script])
        .output()
        .map_err(|e| Error::CryptoFailure(format!("powershell failed: {e}")))?;
    if !output.status.success() {
        return Err(Error::UserDenied);
    }
    let code = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if code.is_empty() {
        return Err(Error::UserDenied);
    }
    Ok(code)
}