1pub mod create;
2pub mod edit;
3pub mod fingerprint;
4pub mod view;
5
6use crate::tools;
7use anyhow::{Result, anyhow};
8use secrecy::{ExposeSecret, SecretString};
9use std::{
10 env,
11 io::{Read, Seek, SeekFrom, Write},
12 process::Command,
13};
14use tempfile::{Builder, NamedTempFile};
15
16#[derive(Debug)]
17pub enum Action {
18 Fingerprint {
19 key: Option<String>,
20 user: Option<String>,
21 },
22 Create {
23 fingerprint: Option<String>,
24 input: Option<String>,
25 json: bool,
26 key: Option<String>,
27 user: Option<String>,
28 vault: Option<String>,
29 },
30 View {
31 key: Option<String>,
32 output: Option<String>,
33 passphrase: Option<SecretString>,
34 vault: Option<String>,
35 },
36 Edit {
37 key: Option<String>,
38 passphrase: Option<SecretString>,
39 vault: String,
40 },
41 Help,
42}
43
44pub fn process_input(buf: &mut Vec<u8>, data: Option<SecretString>) -> Result<usize> {
51 let mut tmpfile = Builder::new()
52 .prefix(".vault-")
53 .suffix(".ssh")
54 .tempfile_in(tools::get_home()?)?;
55
56 if let Some(data) = data {
57 write!(tmpfile, "{}", data.expose_secret())?;
58 }
59
60 let editor = env::var("EDITOR").unwrap_or_else(|_| String::from("vi"));
61
62 let editor_parts = shell_words::split(&editor)?;
63 let command = editor_parts
64 .first()
65 .ok_or_else(|| anyhow!("EDITOR command is empty"))?;
66
67 let status = Command::new(command)
68 .args(editor_parts.get(1..).unwrap_or(&[]))
69 .arg(tmpfile.path())
70 .status()?;
71
72 if !status.success() {
73 return Err(anyhow!("Editor exited with non-zero status code"));
74 }
75
76 read_and_scrub(&mut tmpfile, buf)
77}
78
79fn read_and_scrub(tmpfile: &mut NamedTempFile, buf: &mut Vec<u8>) -> Result<usize> {
93 tmpfile.seek(SeekFrom::Start(0))?;
95 tmpfile.read_to_end(buf)?;
96
97 tmpfile.seek(SeekFrom::Start(0))?;
100 let zeros = vec![0u8; buf.len()];
101 tmpfile.write_all(&zeros)?;
102 tmpfile.as_file().set_len(u64::try_from(buf.len())?)?;
103 tmpfile.as_file().sync_all()?;
104
105 Ok(buf.len())
106}
107
108#[cfg(test)]
109#[allow(clippy::unwrap_used)]
110mod tests {
111 use crate::cli::actions::{Action, create, edit, fingerprint, view};
112 use serde_json::Value;
113 use std::io::Write;
114 use tempfile::NamedTempFile;
115
116 struct Test {
117 input: &'static str,
118 public_key: &'static str,
119 private_key: &'static str,
120 header: &'static str,
121 }
122
123 #[test]
124 fn test_create_view_edit_with_input() {
125 let tests = [
126 Test {
127 input: "Machs na",
128 public_key: "test_data/ed25519.pub",
129 private_key: "test_data/ed25519",
130 header: "SSH-VAULT;CHACHA20-POLY1305",
131 },
132 Test {
133 input: "Machs na",
134 public_key: "test_data/id_rsa.pub",
135 private_key: "test_data/id_rsa",
136 header: "SSH-VAULT;AES256",
137 },
138 Test {
139 input: "Arrachera is a Mexican dish made from marinated and grilled skirt steak. The steak is seasoned with a mixture of spices and marinades, giving it a rich and savory flavor. Commonly served in tacos or fajitas, arrachera is known for its tenderness and versatility in Mexican cuisine",
140 public_key: "test_data/ed25519.pub",
141 private_key: "test_data/ed25519",
142 header: "SSH-VAULT;CHACHA20-POLY1305",
143 },
144 ];
145
146 for test in &tests {
147 let input = test.input;
148 let mut temp_file = NamedTempFile::new().unwrap();
149 temp_file.write_all(input.as_bytes()).unwrap();
150 let vault_file = NamedTempFile::new().unwrap();
151
152 let create = Action::Create {
153 fingerprint: None,
154 key: Some(test.public_key.to_string()),
155 user: None,
156 vault: Some(vault_file.path().to_str().unwrap().to_string()),
157 json: false,
158 input: Some(temp_file.path().to_str().unwrap().to_string()),
159 };
160 let vault = create::handle(create);
161 assert!(vault.is_ok());
162
163 let vault_contents = std::fs::read_to_string(&vault_file).unwrap();
164 assert!(vault_contents.starts_with(test.header));
165
166 let output = NamedTempFile::new().unwrap();
167 let view = Action::View {
168 key: Some(test.private_key.to_string()),
169 output: Some(output.path().to_str().unwrap().to_string()),
170 passphrase: None,
171 vault: Some(vault_file.path().to_str().unwrap().to_string()),
172 };
173 let vault_view = view::handle(view);
174 assert!(vault_view.is_ok());
175
176 let output = std::fs::read_to_string(output).unwrap();
177 assert_eq!(input, output);
178
179 let edit = Action::Edit {
180 key: Some(test.private_key.to_string()),
181 passphrase: None,
182 vault: vault_file.path().to_str().unwrap().to_string(),
183 };
184
185 temp_env::with_vars([("EDITOR", Some("cat"))], || {
187 let vault_edit = edit::handle(edit);
188 assert!(vault_edit.is_ok());
189 });
190
191 let vault_contents_after_edit = std::fs::read_to_string(&vault_file).unwrap();
192 assert_ne!(vault_contents, vault_contents_after_edit);
193
194 let output = NamedTempFile::new().unwrap();
196 let view = Action::View {
197 key: Some(test.private_key.to_string()),
198 output: Some(output.path().to_str().unwrap().to_string()),
199 passphrase: None,
200 vault: Some(vault_file.path().to_str().unwrap().to_string()),
201 };
202 let vault_view = view::handle(view);
203 assert!(vault_view.is_ok());
204
205 let output = std::fs::read_to_string(output).unwrap();
206 assert_eq!(input, output);
207
208 let create = Action::Create {
210 fingerprint: None,
211 key: Some(test.public_key.to_string()),
212 user: None,
213 vault: Some(vault_file.path().to_str().unwrap().to_string()),
214 json: false,
215 input: Some(temp_file.path().to_str().unwrap().to_string()),
216 };
217 let vault = create::handle(create);
218 assert!(vault.is_err());
219 }
220 }
221
222 #[test]
223 fn test_create_with_json() -> Result<(), Box<dyn std::error::Error>> {
224 let tests = [
225 Test {
226 input: "Three may keep a secret, if two of them are dead",
227 public_key: "test_data/ed25519.pub",
228 private_key: "test_data/ed25519",
229 header: "SSH-VAULT;CHACHA20-POLY1305",
230 },
231 Test {
232 input: "Hello World!",
233 public_key: "test_data/ed25519.pub",
234 private_key: "test_data/ed25519",
235 header: "SSH-VAULT;CHACHA20-POLY1305",
236 },
237 ];
238
239 for test in &tests {
240 let input = test.input;
241 let mut temp_file = NamedTempFile::new().unwrap();
242 temp_file.write_all(input.as_bytes()).unwrap();
243 let vault_json = NamedTempFile::new().unwrap();
244
245 let create = Action::Create {
246 fingerprint: None,
247 key: Some(test.public_key.to_string()),
248 user: None,
249 vault: Some(vault_json.path().to_str().unwrap().to_string()),
250 json: true,
251 input: Some(temp_file.path().to_str().unwrap().to_string()),
252 };
253 let vault = create::handle(create);
254 assert!(vault.is_ok());
255
256 let vault_contents = std::fs::read_to_string(&vault_json).unwrap();
257 let json: Value = serde_json::from_str(&vault_contents).unwrap();
258 let vault_str = json
259 .get("vault")
260 .and_then(|v| v.as_str())
261 .ok_or("Failed to get vault from JSON")?;
262
263 let mut vault_file = NamedTempFile::new().unwrap();
264 vault_file.write_all(vault_str.as_bytes()).unwrap();
265 let output = NamedTempFile::new().unwrap();
266
267 let view = Action::View {
268 key: Some(test.private_key.to_string()),
269 output: Some(output.path().to_str().unwrap().to_string()),
270 passphrase: None,
271 vault: Some(vault_file.path().to_str().unwrap().to_string()),
272 };
273 let vault_view = view::handle(view);
274 assert!(vault_view.is_ok());
275
276 let output = std::fs::read_to_string(output).unwrap();
277 assert_eq!(input, output);
278 }
279 Ok(())
280 }
281
282 #[test]
289 fn test_read_and_scrub_overwrites_plaintext() {
290 use super::read_and_scrub;
291 use std::io::{Seek, SeekFrom, Write};
292
293 let secret = b"top secret plaintext";
294 let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
295 tmpfile.write_all(secret).unwrap();
296 tmpfile.seek(SeekFrom::Start(0)).unwrap();
297
298 let mut buf = Vec::new();
299 let n = read_and_scrub(&mut tmpfile, &mut buf).unwrap();
300
301 assert_eq!(n, secret.len());
303 assert_eq!(buf.as_slice(), secret);
304
305 let on_disk = std::fs::read(tmpfile.path()).unwrap();
308 assert_eq!(on_disk.len(), secret.len());
309 assert!(on_disk.iter().all(|&b| b == 0));
310 assert!(!on_disk.windows(secret.len()).any(|w| w == secret));
311 }
312
313 #[test]
316 fn test_view_output_truncates_stale_bytes() {
317 let secret = "short";
318
319 let mut input = NamedTempFile::new().unwrap();
320 input.write_all(secret.as_bytes()).unwrap();
321 let vault_file = NamedTempFile::new().unwrap();
322
323 let create = Action::Create {
324 fingerprint: None,
325 key: Some("test_data/ed25519.pub".to_string()),
326 user: None,
327 vault: Some(vault_file.path().to_str().unwrap().to_string()),
328 json: false,
329 input: Some(input.path().to_str().unwrap().to_string()),
330 };
331 assert!(create::handle(create).is_ok());
332
333 let mut output_file = NamedTempFile::new().unwrap();
335 output_file
336 .write_all(b"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX")
337 .unwrap();
338
339 let view = Action::View {
340 key: Some("test_data/ed25519".to_string()),
341 output: Some(output_file.path().to_str().unwrap().to_string()),
342 passphrase: None,
343 vault: Some(vault_file.path().to_str().unwrap().to_string()),
344 };
345 assert!(view::handle(view).is_ok());
346
347 let contents = std::fs::read_to_string(output_file.path()).unwrap();
349 assert_eq!(contents, secret);
350 }
351
352 #[test]
353 fn test_fingerprint() {
354 let fingerprint = Action::Fingerprint {
355 key: Some("test_data/ed25519.pub".to_string()),
356 user: None,
357 };
358
359 let fingerprint = fingerprint::handle(fingerprint);
360 assert!(fingerprint.is_ok());
361 }
362}