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
16pub const TOTP_DIR_NAME: &str = ".totpc";
18
19const BIN_COMMAND: &str = "totpc";
20pub const COMMAND_HELP: &str = "--help";
22pub const COMMAND_INIT: &str = "init";
24pub const COMMAND_SHORT_INIT: &str = "i";
26pub const COMMAND_COMPUTE: &str = "compute";
28pub const COMMAND_SHORT_COMPUTE: &str = "c";
30pub const COMMAND_LOAD: &str = "read";
32pub const COMMAND_SHORT_LOAD: &str = "r";
34pub const COMMAND_SAVE: &str = "store";
36pub const COMMAND_SHORT_SAVE: &str = "s";
38pub const COMMAND_DELETE: &str = "delete";
40pub const COMMAND_LIST: &str = "list";
42pub 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
49pub 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
72pub 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
99pub 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 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}