Reduce prompt credential lifetime
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
00dce953964d79ea0fad4a00ef61668d52c23a06- Parents
-
08a18d9 - Tree
824c362
00dce95
00dce953964d79ea0fad4a00ef61668d52c23a0608a18d9
824c362| Status | File | + | - |
|---|---|---|---|
| M |
RELEASE_NOTES.md
|
1 | 0 |
| M |
examples/sprint-04-validation-report-2026-02-20.md
|
1 | 0 |
| M |
garcard/src/prompt.rs
|
31 | 15 |
| M |
garcard/src/prompt_ui.rs
|
19 | 1 |
RELEASE_NOTES.mdmodified@@ -10,6 +10,7 @@ | ||
| 10 | 10 | 1. Same-UID enforcement for local IPC control clients. |
| 11 | 11 | 2. Reduced panic surface in prompt color setup paths. |
| 12 | 12 | 3. Best-effort scrubbing of helper prompt response buffers after use. |
| 13 | +4. Reduced prompt credential lifetime by moving submitted input without cloning and scrubbing prompt output buffers. | |
| 13 | 14 | |
| 14 | 15 | ## Validation Coverage |
| 15 | 16 | 1. Sprint 02 live callback and reconnect validation: |
examples/sprint-04-validation-report-2026-02-20.mdmodified@@ -30,6 +30,7 @@ | ||
| 30 | 30 | 1. IPC control path now validates same-UID peer credentials. |
| 31 | 31 | 2. Prompt UI runtime path no longer relies on panic/`expect` for color parsing. |
| 32 | 32 | 3. Helper response buffers are scrubbed after sending to helper socket. |
| 33 | +4. Prompt input handling now moves submitted secrets without cloning and scrubs prompt/output buffers after use. | |
| 33 | 34 | |
| 34 | 35 | ## Remaining Manual Sprint 04 Checks |
| 35 | 36 | 1. Optional interactive acceptance pass (enter valid credentials, wrong-then-retry, explicit cancel) in full desktop session. |
garcard/src/prompt.rsmodified@@ -66,7 +66,7 @@ impl PromptProvider for CommandPrompt { | ||
| 66 | 66 | |
| 67 | 67 | fn run_custom_prompt_command(command: &str, prompt: &str, visible: bool) -> Result<PromptResponse> { |
| 68 | 68 | let mode = if visible { "plain" } else { "secret" }; |
| 69 | - let output = Command::new("sh") | |
| 69 | + let mut output = Command::new("sh") | |
| 70 | 70 | .arg("-c") |
| 71 | 71 | .arg(command) |
| 72 | 72 | .env("GARCARD_PROMPT", prompt) |
@@ -74,10 +74,9 @@ fn run_custom_prompt_command(command: &str, prompt: &str, visible: bool) -> Resu | ||
| 74 | 74 | .output() |
| 75 | 75 | .with_context(|| format!("failed to run custom prompt command: {}", command))?; |
| 76 | 76 | |
| 77 | - Ok(map_output_to_prompt_response( | |
| 78 | - &output.status, | |
| 79 | - &output.stdout, | |
| 80 | - )) | |
| 77 | + let response = map_output_to_prompt_response(&output.status, &output.stdout); | |
| 78 | + scrub_bytes(&mut output.stdout); | |
| 79 | + Ok(response) | |
| 81 | 80 | } |
| 82 | 81 | |
| 83 | 82 | fn run_gartk_prompt_subcommand( |
@@ -88,7 +87,7 @@ fn run_gartk_prompt_subcommand( | ||
| 88 | 87 | let mode = if visible { "plain" } else { "secret" }; |
| 89 | 88 | let executable = |
| 90 | 89 | std::env::current_exe().context("failed to resolve current executable path")?; |
| 91 | - let output = Command::new(executable) | |
| 90 | + let mut output = Command::new(executable) | |
| 92 | 91 | .arg("prompt") |
| 93 | 92 | .arg("--mode") |
| 94 | 93 | .arg(mode) |
@@ -101,13 +100,15 @@ fn run_gartk_prompt_subcommand( | ||
| 101 | 100 | |
| 102 | 101 | if output.status.code() == Some(2) { |
| 103 | 102 | let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); |
| 103 | + scrub_bytes(&mut output.stdout); | |
| 104 | + scrub_bytes(&mut output.stderr); | |
| 104 | 105 | anyhow::bail!("garcard prompt subcommand unavailable: {}", stderr); |
| 105 | 106 | } |
| 106 | 107 | |
| 107 | - Ok(map_output_to_prompt_response( | |
| 108 | - &output.status, | |
| 109 | - &output.stdout, | |
| 110 | - )) | |
| 108 | + let response = map_output_to_prompt_response(&output.status, &output.stdout); | |
| 109 | + scrub_bytes(&mut output.stdout); | |
| 110 | + scrub_bytes(&mut output.stderr); | |
| 111 | + Ok(response) | |
| 111 | 112 | } |
| 112 | 113 | |
| 113 | 114 | fn run_systemd_ask_password( |
@@ -124,13 +125,13 @@ fn run_systemd_ask_password( | ||
| 124 | 125 | } |
| 125 | 126 | command.arg(prompt); |
| 126 | 127 | |
| 127 | - let output = command | |
| 128 | + let mut output = command | |
| 128 | 129 | .output() |
| 129 | 130 | .context("failed to run systemd-ask-password")?; |
| 130 | - Ok(map_output_to_prompt_response( | |
| 131 | - &output.status, | |
| 132 | - &output.stdout, | |
| 133 | - )) | |
| 131 | + let response = map_output_to_prompt_response(&output.status, &output.stdout); | |
| 132 | + scrub_bytes(&mut output.stdout); | |
| 133 | + scrub_bytes(&mut output.stderr); | |
| 134 | + Ok(response) | |
| 134 | 135 | } |
| 135 | 136 | |
| 136 | 137 | fn map_output_to_prompt_response(status: &ExitStatus, stdout: &[u8]) -> PromptResponse { |
@@ -156,6 +157,14 @@ fn extract_response(stdout: &[u8]) -> Option<String> { | ||
| 156 | 157 | } |
| 157 | 158 | } |
| 158 | 159 | |
| 160 | +fn scrub_bytes(value: &mut Vec<u8>) { | |
| 161 | + if value.is_empty() { | |
| 162 | + return; | |
| 163 | + } | |
| 164 | + value.fill(0); | |
| 165 | + value.clear(); | |
| 166 | +} | |
| 167 | + | |
| 159 | 168 | #[cfg(test)] |
| 160 | 169 | mod tests { |
| 161 | 170 | use super::*; |
@@ -181,4 +190,11 @@ mod tests { | ||
| 181 | 190 | let mapped = map_output_to_prompt_response(&status, b""); |
| 182 | 191 | assert_eq!(mapped, PromptResponse::TimedOut); |
| 183 | 192 | } |
| 193 | + | |
| 194 | + #[test] | |
| 195 | + fn scrub_bytes_clears_vec() { | |
| 196 | + let mut value = b"top-secret".to_vec(); | |
| 197 | + scrub_bytes(&mut value); | |
| 198 | + assert!(value.is_empty()); | |
| 199 | + } | |
| 184 | 200 | } |
garcard/src/prompt_ui.rsmodified@@ -195,7 +195,9 @@ impl PromptDialog { | ||
| 195 | 195 | self.exit = Some(PromptExit::Canceled); |
| 196 | 196 | } |
| 197 | 197 | Key::Return => { |
| 198 | - self.exit = Some(PromptExit::Submitted(self.input.clone())); | |
| 198 | + let submitted = std::mem::take(&mut self.input); | |
| 199 | + self.cursor = 0; | |
| 200 | + self.exit = Some(PromptExit::Submitted(submitted)); | |
| 199 | 201 | } |
| 200 | 202 | Key::Left => { |
| 201 | 203 | if self.cursor > 0 { |
@@ -364,6 +366,7 @@ impl PromptDialog { | ||
| 364 | 366 | |
| 365 | 367 | impl Drop for PromptDialog { |
| 366 | 368 | fn drop(&mut self) { |
| 369 | + scrub_string(&mut self.input); | |
| 367 | 370 | let _ = self.window.connection().inner().free_gc(self.gc); |
| 368 | 371 | } |
| 369 | 372 | } |
@@ -421,6 +424,14 @@ fn remove_char_at(input: &mut String, cursor: usize) { | ||
| 421 | 424 | input.drain(start..end); |
| 422 | 425 | } |
| 423 | 426 | |
| 427 | +fn scrub_string(value: &mut String) { | |
| 428 | + if value.is_empty() { | |
| 429 | + return; | |
| 430 | + } | |
| 431 | + let mut bytes = std::mem::take(value).into_bytes(); | |
| 432 | + bytes.fill(0); | |
| 433 | +} | |
| 434 | + | |
| 424 | 435 | #[cfg(test)] |
| 425 | 436 | mod tests { |
| 426 | 437 | use super::*; |
@@ -451,4 +462,11 @@ mod tests { | ||
| 451 | 462 | assert_eq!(display_prefix("hello", 2, PromptMode::Plain), "he"); |
| 452 | 463 | assert_eq!(display_prefix("hello", 2, PromptMode::Secret), "**"); |
| 453 | 464 | } |
| 465 | + | |
| 466 | + #[test] | |
| 467 | + fn scrub_string_clears_input() { | |
| 468 | + let mut value = "top-secret".to_string(); | |
| 469 | + scrub_string(&mut value); | |
| 470 | + assert!(value.is_empty()); | |
| 471 | + } | |
| 454 | 472 | } |