totpc/
lib.rs

1use std::path::Path;
2use std::{fmt::Display, io::stdin};
3
4use compute::compute;
5use file::{
6    delete_key_file, init, list_identifiers, read_decrypted_key_from_file,
7    write_encrypted_key_to_file,
8};
9
10use crate::base32::decode;
11
12mod base32;
13mod compute;
14mod file;
15
16/// Default totpc directory.
17pub const TOTP_DIR_NAME: &str = ".totpc";
18
19const BIN_COMMAND: &str = "totpc";
20/// Help sub command.
21pub const COMMAND_HELP: &str = "--help";
22/// Init command.
23pub const COMMAND_INIT: &str = "init";
24/// Init command shortcut.
25pub const COMMAND_SHORT_INIT: &str = "i";
26/// Compute command.
27pub const COMMAND_COMPUTE: &str = "compute";
28/// Compute command shortcut.
29pub const COMMAND_SHORT_COMPUTE: &str = "c";
30/// Read command.
31pub const COMMAND_LOAD: &str = "read";
32/// Read command shortcut.
33pub const COMMAND_SHORT_LOAD: &str = "r";
34/// Store command.
35pub const COMMAND_SAVE: &str = "store";
36/// Store command shortcut.
37pub const COMMAND_SHORT_SAVE: &str = "s";
38/// Delete command.
39pub const COMMAND_DELETE: &str = "delete";
40/// List command.
41pub const COMMAND_LIST: &str = "list";
42/// List command shortcut.
43pub const COMMAND_SHORT_LIST: &str = "l";
44
45const IDENTIFIER_LIST_HEADER: &str = "totp computer\n";
46const IDENTIFIER_LIST_ITEM_PREFIX: &str = "├─";
47const IDENTIFIER_LIST_LAST_ITEM_PREFIX: &str = "└─";
48
49/// Message to display when command fails.
50pub enum ErrorMessage<'a> {
51    EmptyKey,
52    MissingIdentifier(&'a str),
53}
54
55impl Display for ErrorMessage<'_> {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            Self::EmptyKey => f.write_str("Error: key must not be empty"),
59            Self::MissingIdentifier(command) => f.write_str(&format!(
60                "Error: missing identifier - specify the identifier to {command}"
61            )),
62        }
63    }
64}
65
66impl From<ErrorMessage<'_>> for String {
67    fn from(value: ErrorMessage) -> Self {
68        value.to_string()
69    }
70}
71
72/// Returns the general help text.
73pub fn get_help_text() -> String {
74    format!(
75        "TOTP Computer - time-based one time password computer
76
77Usage:
78    {BIN_COMMAND} [{COMMAND_INIT}, {COMMAND_SHORT_INIT}] <gpg-id>
79        Initialize totp computer with gpg-id for encrypting keys.
80
81    {BIN_COMMAND} [{COMMAND_LIST}, {COMMAND_SHORT_LIST}]
82        List all stored identifiers.
83
84    {BIN_COMMAND} [{COMMAND_COMPUTE}, {COMMAND_SHORT_COMPUTE}] <identifier>
85        Compute current one time password for given identifier.
86
87    {BIN_COMMAND} {COMMAND_DELETE} <identifier>
88        Delete identifier and key from store.
89
90    {BIN_COMMAND} [{COMMAND_LOAD}, {COMMAND_SHORT_LOAD}] <identifier>
91        Decrypt and output key of given identifier.
92
93    {BIN_COMMAND} [{COMMAND_SAVE}, {COMMAND_SHORT_SAVE}] <identifier>
94        Save key for given identifier.
95        Prompts to overwrite existing files."
96    )
97}
98
99/// Identifies entered command and calls corresponding function.
100///
101/// # Errors
102///
103/// Returns error when command is unknown.
104pub fn run(gpg_home_dir: &Path, totp_dir: &Path, args: Vec<String>) -> Result<String, String> {
105    let command = {
106        if args.len() < 2 {
107            COMMAND_LIST
108        } else {
109            args[1].as_str()
110        }
111    };
112
113    match command {
114        COMMAND_INIT | COMMAND_SHORT_INIT => {
115            if args.len() < 3 {
116                return Err(
117                    "Error: gpg id required for initialization - totp init <gpg_id>".to_string(),
118                );
119            }
120            let gpg_id = args[2].as_str();
121            init(totp_dir, gpg_id)?;
122            Ok(format!("totp computer initialized with gpg id {}", gpg_id))
123        }
124        COMMAND_LIST | COMMAND_SHORT_LIST => Ok(print_list(&list_identifiers(totp_dir)?)),
125        COMMAND_SAVE | COMMAND_SHORT_SAVE => {
126            if args.len() < 3 {
127                return Err(ErrorMessage::MissingIdentifier(COMMAND_SAVE).into());
128            }
129            let identifier = args[2].as_str();
130            let key_base32 = read_key_input(identifier)?;
131            write_encrypted_key_to_file(gpg_home_dir, totp_dir, identifier, &key_base32)?;
132            Ok(format!("Key for {identifier} stored."))
133        }
134        COMMAND_LOAD | COMMAND_SHORT_LOAD => {
135            if args.len() < 3 {
136                return Err(ErrorMessage::MissingIdentifier(COMMAND_LOAD).into());
137            }
138            let identifier = args[2].as_str();
139            match read_decrypted_key_from_file(gpg_home_dir, totp_dir, identifier)? {
140                None => Ok(format!("Identifier {identifier} not found.")),
141                Some(key) => Ok(format!("Key for {identifier}: {key}")),
142            }
143        }
144        COMMAND_DELETE => {
145            if args.len() < 3 {
146                return Err(ErrorMessage::MissingIdentifier(COMMAND_DELETE).into());
147            }
148            let identifier = args[2].as_str();
149            delete_key_file(totp_dir, identifier)?;
150            Ok(format!("Key for {identifier} deleted."))
151        }
152        COMMAND_COMPUTE | COMMAND_SHORT_COMPUTE => {
153            if args.len() < 3 {
154                return Err(ErrorMessage::MissingIdentifier(COMMAND_COMPUTE).into());
155            }
156            let identifier = args[2].as_str();
157            let maybe_key_base32 = read_decrypted_key_from_file(gpg_home_dir, totp_dir, identifier)
158                .map_err(|error| format!("Error reading file - {error}"))?;
159            match maybe_key_base32 {
160                None => Err(format!("Error: no entry found for {identifier}")),
161                Some(key_base32) => {
162                    let key = decode(&key_base32)?;
163                    let time = std::time::SystemTime::UNIX_EPOCH
164                        .elapsed()
165                        .map_err(|error| {
166                            format!("Error: could not determine current system time - {error}",)
167                        })?
168                        .as_secs();
169                    let time_step_interval = 30;
170                    let time_step = time / time_step_interval;
171                    let totp = compute(&key, time_step)?;
172                    Ok(format!("Current TOTP for {identifier} is {totp}"))
173                }
174            }
175        }
176        COMMAND_HELP => Ok(get_help_text()),
177        _ => Err(format!(
178            "Error: unknown command \"{command}\"\n\n{}",
179            get_help_text()
180        )),
181    }
182}
183
184fn read_key_input(identifier: &str) -> Result<String, String> {
185    println!("Enter key for {identifier}: ");
186    let mut key_base32 = String::new();
187    stdin()
188        .read_line(&mut key_base32)
189        .map_err(|error| format!("Error entering key: {}", error))?;
190    if key_base32.is_empty() {
191        return Err(ErrorMessage::EmptyKey.into());
192    }
193    key_base32 = key_base32
194        .trim()
195        .replace(' ', "")
196        .to_string()
197        .to_uppercase();
198    // verify valid Base32 encoding of key
199    base32::decode(&key_base32)?;
200    Ok(key_base32)
201}
202
203fn print_list(identifier_list: &[String]) -> String {
204    let mut printed_list = String::from(IDENTIFIER_LIST_HEADER);
205    if let Some((last_identifier, identifiers)) = identifier_list.split_last() {
206        for identifier in identifiers {
207            printed_list.push_str(format!("{IDENTIFIER_LIST_ITEM_PREFIX} {identifier}\n").as_str())
208        }
209        printed_list
210            .push_str(format!("{IDENTIFIER_LIST_LAST_ITEM_PREFIX} {}", last_identifier).as_str());
211    } else {
212        printed_list.push_str("--- empty ---")
213    }
214    printed_list
215}
216
217#[cfg(test)]
218mod tests {
219    use crate::{
220        print_list, IDENTIFIER_LIST_HEADER, IDENTIFIER_LIST_ITEM_PREFIX,
221        IDENTIFIER_LIST_LAST_ITEM_PREFIX,
222    };
223
224    #[test]
225    fn print_identifier_list() {
226        let identifiers = vec![
227            String::from("identifier_1"),
228            String::from("identifier_2"),
229            String::from("identifier_3"),
230        ];
231        let mut expected_printed_list = IDENTIFIER_LIST_HEADER.to_string();
232        identifiers[0..identifiers.len() - 1]
233            .iter()
234            .for_each(|identifier| {
235                expected_printed_list
236                    .push_str(format!("{IDENTIFIER_LIST_ITEM_PREFIX} {identifier}\n").as_str())
237            });
238        expected_printed_list.push_str(
239            format!(
240                "{IDENTIFIER_LIST_LAST_ITEM_PREFIX} {}",
241                identifiers.last().unwrap()
242            )
243            .as_str(),
244        );
245
246        let printed_list = print_list(&identifiers);
247
248        assert_eq!(printed_list, expected_printed_list);
249    }
250}