Skip to main content

rustic_rs/commands/
key.rs

1//! `key` subcommand
2
3use crate::{
4    Application, RUSTIC_APP, helpers::table_with_titles, repository::OpenRepo, status_err,
5};
6
7use std::path::PathBuf;
8
9use abscissa_core::{Command, Runnable, Shutdown};
10use anyhow::{Result, bail};
11use dialoguer::Password;
12use log::{info, warn};
13
14use qrcode::{QrCode, render::svg};
15use rustic_core::{
16    CommandInput, CredentialOptions, Credentials, KeyOptions,
17    repofile::{KeyFile, MasterKey},
18};
19
20/// `key` subcommand
21#[derive(clap::Parser, Command, Debug)]
22pub(super) struct KeyCmd {
23    /// Subcommand to run
24    #[clap(subcommand)]
25    cmd: KeySubCmd,
26}
27
28impl Runnable for KeyCmd {
29    fn run(&self) {
30        self.cmd.run();
31    }
32}
33
34#[derive(clap::Subcommand, Debug, Runnable)]
35enum KeySubCmd {
36    /// Add a new key to the repository
37    Add(AddCmd),
38    /// List all keys in the repository
39    List(ListCmd),
40    /// Remove a key from the repository
41    Remove(RemoveCmd),
42    /// Change the password of a key
43    Password(PasswordCmd),
44    /// Export the masterkey
45    Export(ExportCmd),
46    /// Create a new masterkey
47    Create(CreateCmd),
48}
49
50#[derive(clap::Parser, Debug)]
51pub(crate) struct NewPasswordOptions {
52    /// New password
53    #[clap(long)]
54    pub(crate) new_password: Option<String>,
55
56    /// File from which to read the new password
57    #[clap(long)]
58    pub(crate) new_password_file: Option<PathBuf>,
59
60    /// Command to get the new password from
61    #[clap(long)]
62    pub(crate) new_password_command: Option<CommandInput>,
63}
64
65impl NewPasswordOptions {
66    fn pass(&self, text: &str) -> Result<String> {
67        // create new credential options which just contain password information
68        let mut pass_opts = CredentialOptions::default();
69        pass_opts.password = self.new_password.clone();
70        pass_opts.password_file = self.new_password_file.clone();
71        pass_opts.password_command = self.new_password_command.clone();
72
73        let pass = if let Some(Credentials::Password(pass)) = pass_opts.credentials()? {
74            pass
75        } else {
76            Password::new()
77                .with_prompt(text)
78                .allow_empty_password(true)
79                .with_confirmation("confirm password", "passwords do not match")
80                .interact()?
81        };
82        Ok(pass)
83    }
84}
85
86#[derive(clap::Parser, Debug)]
87pub(crate) struct AddCmd {
88    /// New password options
89    #[clap(flatten)]
90    pub(crate) pass_opts: NewPasswordOptions,
91
92    /// Key options
93    #[clap(flatten)]
94    pub(crate) key_opts: KeyOptions,
95}
96
97impl Runnable for AddCmd {
98    fn run(&self) {
99        if let Err(err) = RUSTIC_APP
100            .config()
101            .repository
102            .run_open(|repo| self.inner_run(repo))
103        {
104            status_err!("{}", err);
105            RUSTIC_APP.shutdown(Shutdown::Crash);
106        };
107    }
108}
109
110impl AddCmd {
111    fn inner_run(&self, repo: OpenRepo) -> Result<()> {
112        if RUSTIC_APP.config().global.dry_run {
113            info!("adding no key in dry-run mode.");
114            return Ok(());
115        }
116        let pass = self.pass_opts.pass("enter password for new key")?;
117        let id = repo.add_key(&pass, &self.key_opts)?;
118        info!("key {id} successfully added.");
119
120        Ok(())
121    }
122}
123
124#[derive(clap::Parser, Debug)]
125pub(crate) struct ListCmd;
126
127impl Runnable for ListCmd {
128    fn run(&self) {
129        if let Err(err) = RUSTIC_APP
130            .config()
131            .repository
132            .run_open(|repo| self.inner_run(repo))
133        {
134            status_err!("{}", err);
135            RUSTIC_APP.shutdown(Shutdown::Crash);
136        };
137    }
138}
139
140impl ListCmd {
141    fn inner_run(&self, repo: OpenRepo) -> Result<()> {
142        let used_key = repo.key_id();
143        let keys = repo
144            .stream_files()?
145            .inspect(|f| {
146                if let Err(err) = f {
147                    warn!("{err:?}");
148                }
149            })
150            .filter_map(Result::ok);
151
152        let mut table = table_with_titles(["ID", "User", "Host", "Created"]);
153        _ = table.add_rows(keys.map(|key: (_, KeyFile)| {
154            [
155                format!(
156                    "{}{}",
157                    if used_key == &Some(key.0) { "*" } else { "" },
158                    key.0
159                ),
160                key.1.username.unwrap_or_default(),
161                key.1.hostname.unwrap_or_default(),
162                key.1
163                    .created
164                    .map_or(String::new(), |time| format!("{time}")),
165            ]
166        }));
167        println!("{table}");
168        Ok(())
169    }
170}
171
172#[derive(clap::Parser, Debug)]
173pub(crate) struct RemoveCmd {
174    /// The keys to remove
175    ids: Vec<String>,
176}
177
178impl Runnable for RemoveCmd {
179    fn run(&self) {
180        if let Err(err) = RUSTIC_APP
181            .config()
182            .repository
183            .run_open(|repo| self.inner_run(repo))
184        {
185            status_err!("{}", err);
186            RUSTIC_APP.shutdown(Shutdown::Crash);
187        };
188    }
189}
190
191impl RemoveCmd {
192    fn inner_run(&self, repo: OpenRepo) -> Result<()> {
193        let repo_key = repo.key_id();
194        let ids: Vec<_> = repo.find_ids(&self.ids)?.collect();
195        if ids.iter().any(|id| Some(id) == repo_key.as_ref()) {
196            bail!("Cannot remove currently used key!");
197        }
198        if !RUSTIC_APP.config().global.dry_run {
199            for id in ids {
200                repo.delete_key(&id)?;
201                info!("key {id} successfully removed.");
202            }
203            return Ok(());
204        }
205
206        let keys = repo
207            .stream_files_list(&ids)?
208            .inspect(|f| {
209                if let Err(err) = f {
210                    warn!("{err:?}");
211                }
212            })
213            .filter_map(Result::ok);
214
215        let mut table = table_with_titles(["ID", "User", "Host", "Created"]);
216        _ = table.add_rows(keys.map(|key: (_, KeyFile)| {
217            [
218                key.0.to_string(),
219                key.1.username.unwrap_or_default(),
220                key.1.hostname.unwrap_or_default(),
221                key.1
222                    .created
223                    .map_or(String::new(), |time| format!("{time}")),
224            ]
225        }));
226        println!("would have removed the following keys:");
227        println!("{table}");
228        Ok(())
229    }
230}
231
232#[derive(clap::Parser, Debug)]
233pub(crate) struct PasswordCmd {
234    /// New password options
235    #[clap(flatten)]
236    pub(crate) pass_opts: NewPasswordOptions,
237}
238
239impl Runnable for PasswordCmd {
240    fn run(&self) {
241        if let Err(err) = RUSTIC_APP
242            .config()
243            .repository
244            .run_open(|repo| self.inner_run(repo))
245        {
246            status_err!("{}", err);
247            RUSTIC_APP.shutdown(Shutdown::Crash);
248        };
249    }
250}
251
252impl PasswordCmd {
253    fn inner_run(&self, repo: OpenRepo) -> Result<()> {
254        let Some(key_id) = repo.key_id() else {
255            bail!("No keyfile used to open the repo. Cannot change the password.")
256        };
257        if RUSTIC_APP.config().global.dry_run {
258            info!("changing no password in dry-run mode.");
259            return Ok(());
260        }
261        let pass = self.pass_opts.pass("enter new password")?;
262        let old_key: KeyFile = repo.get_file(key_id)?;
263        let key_opts = KeyOptions::default()
264            .hostname(old_key.hostname)
265            .username(old_key.username)
266            .with_created(old_key.created.is_some());
267        let id = repo.add_key(&pass, &key_opts)?;
268        info!("key {id} successfully added.");
269
270        let old_key = *key_id; // copy key, as we need to use repo as reference
271        // re-open repository using new password
272        let repo = repo.open(&Credentials::Password(pass))?;
273        repo.delete_key(&old_key)?;
274        info!("key {old_key} successfully removed.");
275
276        Ok(())
277    }
278}
279
280#[derive(clap::Parser, Debug)]
281pub(crate) struct ExportCmd {
282    /// Write to file if given, else to stdout
283    pub(crate) file: Option<PathBuf>,
284
285    /// Generate a QR code in svg format
286    #[clap(long)]
287    pub(crate) qr: bool,
288}
289
290impl Runnable for ExportCmd {
291    fn run(&self) {
292        if let Err(err) = RUSTIC_APP.config().repository.run_open(|repo| {
293            let mut data = serde_json::to_string(&repo.key())?;
294            if self.qr {
295                let qr = QrCode::new(&data)?;
296                data = qr.render::<svg::Color<'_>>().build();
297            }
298            match &self.file {
299                None => println!("{}", data),
300                Some(file) => std::fs::write(file, data)?,
301            }
302            Ok(())
303        }) {
304            status_err!("{}", err);
305            RUSTIC_APP.shutdown(Shutdown::Crash);
306        };
307    }
308}
309
310#[derive(clap::Parser, Debug)]
311pub(crate) struct CreateCmd {
312    /// Write to file if given, else to stdout
313    pub(crate) file: Option<PathBuf>,
314}
315
316impl Runnable for CreateCmd {
317    fn run(&self) {
318        let inner = || -> Result<_> {
319            let data = serde_json::to_string(&MasterKey::new())?;
320            match &self.file {
321                None => println!("{}", data),
322                Some(file) => std::fs::write(file, data)?,
323            }
324            Ok(())
325        };
326        if let Err(err) = inner() {
327            status_err!("{}", err);
328            RUSTIC_APP.shutdown(Shutdown::Crash);
329        };
330    }
331}