Skip to main content

isoloom_render/secrets/
windows.rs

1//! Secrets on a Windows VM, in PowerShell. Delivery keeps each value under [`KEPT_DIR`], a
2//! folder only SYSTEM and Administrators can open, and leaves the mark [`DELIVERED`] the set-up
3//! waits for; each file is then written at its path with the same two readers and nobody else.
4//!
5//! The values come on the connection's standard input (WinRM's or OpenSSH's), after the
6//! receiving script itself: the command line the runner starts is [`BOOTSTRAP`], the same
7//! for every machine, which reads the script's first line and runs it. Nothing of a value is
8//! in a command line, a generated file or the output.
9//!
10//! At every boot a startup task (`isoloom-apply-secrets`) writes the files again. Task
11//! Scheduler starts the startup tasks in no fixed order, so the `before` and command scripts
12//! run the same placing script first themselves: their process never starts without its files.
13
14use isoloom_plan::SecretFile;
15use isoloom_spec::model::is_drive_path;
16use isoloom_spec::shell::base64;
17
18use crate::powershell;
19
20/// Where a Windows machine keeps the secrets delivered to it.
21pub const KEPT_DIR: &str = "C:\\ProgramData\\isoloom\\secrets";
22/// The mark delivery leaves once every secret is kept.
23pub const DELIVERED: &str = "C:\\ProgramData\\isoloom\\secrets\\.delivered";
24/// The script putting the files in place, run by the startup task and before the process.
25pub const PLACE_SCRIPT: &str = "C:\\ProgramData\\isoloom\\apply-secrets.ps1";
26/// The startup task running [`PLACE_SCRIPT`] at every boot.
27pub const PLACE_TASK: &str = "isoloom-apply-secrets";
28
29/// What the runner starts on the machine (as `powershell -EncodedCommand`): it reads the
30/// first line of its standard input, the receiving script in base64, and runs it. The script
31/// reads the values from the lines that follow.
32pub const BOOTSTRAP: &str = "$s = [Console]::In.ReadLine(); Invoke-Expression ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($s)))";
33
34/// Limits a file or folder to SYSTEM (S-1-5-18) and Administrators (S-1-5-32-544), nothing
35/// inherited: a new access list replaces whatever the path had.
36const PROTECT: &str = "function Protect-IsoloomPath($p) { $dir = Test-Path -PathType Container $p; \
37$acl = if ($dir) { New-Object Security.AccessControl.DirectorySecurity } else { New-Object Security.AccessControl.FileSecurity }; \
38$acl.SetAccessRuleProtection($true, $false); $inherit = if ($dir) { 'ContainerInherit, ObjectInherit' } else { 'None' }; \
39foreach ($sid in 'S-1-5-18', 'S-1-5-32-544') { $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule((New-Object Security.Principal.SecurityIdentifier $sid), 'FullControl', $inherit, 'None', 'Allow'))) }; \
40Set-Acl -Path $p -AclObject $acl }";
41
42/// The path of a secret file on Windows: a drive path as it is, a path from `/` on drive C:.
43#[must_use]
44pub fn path(p: &str) -> String {
45    if is_drive_path(p) {
46        p.to_owned()
47    } else {
48        format!("C:{}", p.replace('/', "\\"))
49    }
50}
51
52/// The receiving script. After the line that holds it, its standard input holds one line per
53/// secret of `names`, in that order, each value in base64; it keeps each one, marks the
54/// delivery, then puts `files` in place (so a run again refreshes them).
55#[must_use]
56pub fn receive(names: &[String], files: &[SecretFile]) -> String {
57    let list = names.iter().map(|n| powershell::quote(n)).collect::<Vec<_>>().join(", ");
58    format!(
59        "$ErrorActionPreference = 'Stop'\n{PROTECT}\n$k = {k}\nNew-Item -ItemType Directory -Force $k | Out-Null\nProtect-IsoloomPath $k\n\
60         foreach ($n in @({list})) {{ $v = [Console]::In.ReadLine(); if ($null -eq $v) {{ throw \"no value came for $n\" }}; [IO.File]::WriteAllBytes(\"$k\\$n.new\", [Convert]::FromBase64String($v.Trim())); Move-Item -Force \"$k\\$n.new\" \"$k\\$n\" }}\n\
61         Remove-Variable v\nSet-Content -Path {} -Value ''\n{}",
62        powershell::quote(DELIVERED),
63        place(files),
64        k = powershell::quote(KEPT_DIR)
65    )
66}
67
68/// Each kept secret written at its path, its folder made when missing. The file is created
69/// empty and limited to SYSTEM and Administrators before the value is written into it, so it
70/// never holds the value with its folder's (wider) access list.
71#[must_use]
72pub fn place(files: &[SecretFile]) -> String {
73    let set: String = files
74        .iter()
75        .map(|f| format!("Set-IsoloomSecret {} {}\n", powershell::quote(&f.name), powershell::quote(&path(&f.path))))
76        .collect::<Vec<_>>()
77        .concat();
78    format!(
79        "$ErrorActionPreference = 'Stop'\n{PROTECT}\n$k = {}\n\
80         function Set-IsoloomSecret($name, $path) {{ New-Item -ItemType Directory -Force (Split-Path -Parent $path) | Out-Null; \
81         if (-not (Test-Path $path)) {{ New-Item -ItemType File $path | Out-Null }}; Protect-IsoloomPath $path; \
82         [IO.File]::WriteAllBytes($path, [IO.File]::ReadAllBytes((Join-Path $k $name))) }}\n{set}",
83        powershell::quote(KEPT_DIR)
84    )
85}
86
87/// Waits for the delivery mark, saying so once; fails after
88/// [`DELIVERY_TIMEOUT`](super::DELIVERY_TIMEOUT) seconds with what to do.
89#[must_use]
90pub fn wait(who: &str) -> String {
91    let (waiting, missing) = super::messages(who);
92    let (waiting, missing) = (powershell::quote(&waiting), powershell::quote(&missing));
93    format!(
94        "$t = (Get-Date).AddSeconds({}); $said = $false; while (-not (Test-Path {})) {{ if (-not $said) {{ Write-Output {waiting}; $said = $true }}; if ((Get-Date) -gt $t) {{ throw {missing} }}; Start-Sleep 5 }}\n",
95        super::DELIVERY_TIMEOUT,
96        powershell::quote(DELIVERED)
97    )
98}
99
100/// A Windows VM's `Secrets` step: the wait, then the placing script written, registered as a
101/// startup task (as SYSTEM) and run now.
102#[must_use]
103pub fn step(machine: &str, files: &[SecretFile]) -> String {
104    format!("{}{}", wait(machine), placed(files))
105}
106
107/// The placing script written, registered as a startup task (as SYSTEM) and run now.
108fn placed(files: &[SecretFile]) -> String {
109    format!(
110        "Set-Content -Path {script} -Value {body}\n\
111         $a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -ExecutionPolicy Bypass -File {PLACE_SCRIPT}'; \
112         Register-ScheduledTask -TaskName '{PLACE_TASK}' -Action $a -Trigger (New-ScheduledTaskTrigger -AtStartup) -User 'SYSTEM' -RunLevel Highest -Force | Out-Null\n\
113         & powershell -NoProfile -ExecutionPolicy Bypass -File {script}; if ($LASTEXITCODE) {{ exit $LASTEXITCODE }}\n",
114        script = powershell::quote(PLACE_SCRIPT),
115        body = powershell::quote(&place(files)),
116    )
117}
118
119/// A Windows VM's `Secrets` step when the tool that runs it hands the values over itself
120/// (Vagrant's provisioner environment over WinRM): each of `values`, a secret's name and the
121/// variable holding its value in base64 (PowerShell never expands anything in it), kept in the
122/// SYSTEM- and Administrators-only folder and the delivery marked; then the placing script
123/// written, registered and run, as [`step`] does. Nothing waits. An empty or unset variable
124/// writes an empty value the first time, and keeps the value already kept after.
125#[must_use]
126pub fn from_environment(values: &[(String, String)], files: &[SecretFile]) -> String {
127    let pairs = values
128        .iter()
129        .map(|(name, var)| format!("{} = {}", powershell::quote(name), powershell::quote(var)))
130        .collect::<Vec<_>>()
131        .join("; ");
132    format!(
133        "$ErrorActionPreference = 'Stop'\n{PROTECT}\n$k = {k}\nNew-Item -ItemType Directory -Force $k | Out-Null\nProtect-IsoloomPath $k\n\
134         foreach ($e in @{{ {pairs} }}.GetEnumerator()) {{ $v = [Environment]::GetEnvironmentVariable($e.Value); \
135         if (-not $v -and (Test-Path \"$k\\$($e.Key)\")) {{ continue }}; \
136         $b = if ($v) {{ [Convert]::FromBase64String($v) }} else {{ [byte[]]@() }}; \
137         [IO.File]::WriteAllBytes(\"$k\\$($e.Key).new\", $b); Move-Item -Force \"$k\\$($e.Key).new\" \"$k\\$($e.Key)\" }}\n\
138         Set-Content -Path {} -Value ''\n{}",
139        powershell::quote(DELIVERED),
140        placed(files),
141        k = powershell::quote(KEPT_DIR),
142    )
143}
144
145/// The line a `before` or command script starts with on a machine that gets secrets: the files
146/// put in place first, whichever startup task Windows ran first.
147#[must_use]
148pub fn place_first() -> String {
149    format!(
150        "& powershell -NoProfile -ExecutionPolicy Bypass -File {}; if ($LASTEXITCODE) {{ exit $LASTEXITCODE }}",
151        powershell::quote(PLACE_SCRIPT)
152    )
153}
154
155/// The arguments of `powershell.exe` running [`BOOTSTRAP`], encoded (UTF-16LE in base64) so
156/// that no remote shell (cmd, PowerShell) reads anything into it.
157#[must_use]
158pub fn powershell_args() -> Vec<String> {
159    let bytes: Vec<u8> = BOOTSTRAP.encode_utf16().flat_map(u16::to_le_bytes).collect();
160    let mut args: Vec<String> = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand"]
161        .map(String::from)
162        .to_vec();
163    args.push(base64(&bytes));
164    args
165}
166
167/// The command line running [`BOOTSTRAP`] through a remote shell (OpenSSH's): `powershell`
168/// and [`powershell_args`], none of which a shell changes.
169#[must_use]
170pub fn command_line() -> String {
171    format!("powershell {}", powershell_args().join(" "))
172}
173
174/// What the deliverer writes on the connection's standard input: the receiving script in
175/// base64 on the first line, then `values` (one base64 line per secret, in `names`' order).
176#[must_use]
177pub fn input(names: &[String], files: &[SecretFile], values: &[u8]) -> Vec<u8> {
178    let mut out = base64(receive(names, files).as_bytes()).into_bytes();
179    out.push(b'\n');
180    out.extend_from_slice(values);
181    out
182}
183
184#[cfg(test)]
185mod tests;