//! 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)
}