Skip to content

Commit ce603f2

Browse files
authored
Support import / export of OTP Uri bulk (#317)
This PR implements import / export logic from a bulk of OTP Uris. Closes #296 Closes #263
2 parents 6228967 + 27165c4 commit ce603f2

File tree

15 files changed

+505
-137
lines changed

15 files changed

+505
-137
lines changed

Cargo.lock

Lines changed: 259 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,5 @@ base64 = "0.21.4"
5252
md-5 = "0.10.6"
5353
ratatui = { version = "0.23.0", features = ["all-widgets"] }
5454
crossterm = "0.27.0"
55+
url = "2.4.1"
56+
color-eyre = "0.6.2"

build.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::env;
2+
use std::process::Command;
23

34
fn main() {
45
let version = env!("CARGO_PKG_VERSION");
@@ -7,6 +8,24 @@ fn main() {
78
println!("cargo:rustc-env=COTP_VERSION={}", version);
89
} else {
910
// Suffix with -DEBUG
10-
println!("cargo:rustc-env=COTP_VERSION={}-DEBUG", version);
11+
// If we can get the last commit hash, let's append that also
12+
if let Some(last_commit) = get_last_commit() {
13+
println!(
14+
"cargo:rustc-env=COTP_VERSION={}-DEBUG-{}",
15+
version, last_commit
16+
);
17+
} else {
18+
println!("cargo:rustc-env=COTP_VERSION={}-DEBUG", version);
19+
}
1120
}
1221
}
22+
23+
fn get_last_commit() -> Option<String> {
24+
Command::new("git")
25+
.args(["rev-parse", "--short=12", "HEAD"])
26+
.output()
27+
.ok()
28+
.filter(|e| e.status.success())
29+
.map(|e| String::from_utf8(e.stdout))
30+
.and_then(|e| e.ok())
31+
}

src/args.rs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::path::PathBuf;
22

33
use clap::{Args, Parser, Subcommand};
4+
use color_eyre::eyre::eyre;
45

56
use crate::{
67
argument_functions, dashboard,
@@ -31,20 +32,20 @@ enum CotpSubcommands {
3132
#[derive(Args)]
3233
pub struct AddArgs {
3334
/// Add OTP code via an OTP URI
34-
#[arg(short = 'u', long = "otpuri", required_unless_present = "issuer")]
35+
#[arg(short = 'u', long = "otpuri", required_unless_present = "label")]
3536
pub otp_uri: bool,
3637

3738
/// Specify the OTP code type
3839
#[arg(short = 't', long = "type", default_value = "totp")]
3940
pub otp_type: OTPType,
4041

4142
/// Code issuer
42-
#[arg(short, long, required_unless_present = "otp_uri")]
43-
pub issuer: Option<String>,
43+
#[arg(short, long, default_value = "")]
44+
pub issuer: String,
4445

4546
/// Code label
46-
#[arg(short, long, default_value = "")]
47-
pub label: String,
47+
#[arg(short, long, required_unless_present = "otp_uri")]
48+
pub label: Option<String>,
4849

4950
/// OTP Algorithm
5051
#[arg(short, long, value_enum, default_value_t = OTPAlgorithm::Sha1)]
@@ -179,6 +180,10 @@ pub struct BackupType {
179180
/// Import from Microsoft Authenticator
180181
#[arg(short = 'm', long = "microsoft-authenticator")]
181182
pub microsoft_authenticator: bool,
183+
184+
/// Import from OTP Uri batch
185+
#[arg(short, long = "otp-uri")]
186+
pub otp_uri: bool,
182187
}
183188

184189
#[derive(Args)]
@@ -188,29 +193,34 @@ pub struct ExportFormat {
188193
#[arg(short, long)]
189194
pub cotp: bool,
190195

191-
/// Import from andOTP backup
196+
/// Export into andOTP backup
192197
#[arg(short = 'e', long)]
193198
pub andotp: bool,
199+
200+
/// Export into an OTP URI
201+
#[arg(short, long = "otp-uri")]
202+
pub otp_uri: bool,
194203
}
195204

196205
impl Default for ExportFormat {
197206
fn default() -> Self {
198207
Self {
199208
cotp: true,
200209
andotp: false,
210+
otp_uri: false,
201211
}
202212
}
203213
}
204214

205-
pub fn args_parser(matches: CotpArgs, read_result: OTPDatabase) -> Result<OTPDatabase, String> {
215+
pub fn args_parser(matches: CotpArgs, read_result: OTPDatabase) -> color_eyre::Result<OTPDatabase> {
206216
match matches.command {
207217
Some(CotpSubcommands::Add(args)) => argument_functions::add(args, read_result),
208218
Some(CotpSubcommands::Edit(args)) => argument_functions::edit(args, read_result),
209219
Some(CotpSubcommands::Import(args)) => argument_functions::import(args, read_result),
210220
Some(CotpSubcommands::Export(args)) => argument_functions::export(args, read_result),
211221
Some(CotpSubcommands::Passwd) => argument_functions::change_password(read_result),
212222
// no args, show dashboard
213-
None => dashboard(read_result).map_err(|e| format!("{:?}", e)),
223+
None => dashboard(read_result).map_err(|e| eyre!("An error occurred: {e}")),
214224
}
215225
}
216226

src/argument_functions.rs

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,54 @@
11
use crate::args::{AddArgs, EditArgs, ExportArgs, ImportArgs};
22
use crate::exporters::do_export;
3+
use crate::exporters::otp_uri::OtpUriList;
34
use crate::importers::aegis::AegisJson;
45
use crate::importers::aegis_encrypted::AegisEncryptedDatabase;
56
use crate::importers::authy_remote_debug::AuthyExportedList;
67
use crate::importers::converted::ConvertedJsonList;
78
use crate::importers::freeotp_plus::FreeOTPPlusJson;
9+
use crate::importers::importer::import_from_path;
810
use crate::otp::from_otp_uri::FromOtpUri;
911
use crate::otp::otp_element::{OTPDatabase, OTPElement};
10-
use crate::{importers, utils};
12+
use crate::utils;
13+
use color_eyre::eyre::{eyre, ErrReport};
1114
use zeroize::Zeroize;
1215

13-
pub fn import(matches: ImportArgs, mut database: OTPDatabase) -> Result<OTPDatabase, String> {
16+
pub fn import(matches: ImportArgs, mut database: OTPDatabase) -> color_eyre::Result<OTPDatabase> {
1417
let path = matches.path;
1518

1619
let backup_type = matches.backup_type;
1720

1821
let result = if backup_type.cotp {
19-
importers::importer::import_from_path::<OTPDatabase>(path)
22+
import_from_path::<OTPDatabase>(path)
2023
} else if backup_type.andotp {
21-
importers::importer::import_from_path::<Vec<OTPElement>>(path)
24+
import_from_path::<Vec<OTPElement>>(path)
2225
} else if backup_type.aegis {
23-
importers::importer::import_from_path::<AegisJson>(path)
26+
import_from_path::<AegisJson>(path)
2427
} else if backup_type.aegis_encrypted {
25-
importers::importer::import_from_path::<AegisEncryptedDatabase>(path)
28+
import_from_path::<AegisEncryptedDatabase>(path)
2629
} else if backup_type.freeotp_plus {
27-
importers::importer::import_from_path::<FreeOTPPlusJson>(path)
30+
import_from_path::<FreeOTPPlusJson>(path)
2831
} else if backup_type.authy_exported {
29-
importers::importer::import_from_path::<AuthyExportedList>(path)
32+
import_from_path::<AuthyExportedList>(path)
3033
} else if backup_type.google_authenticator
3134
|| backup_type.authy
3235
|| backup_type.microsoft_authenticator
3336
|| backup_type.freeotp
3437
{
35-
importers::importer::import_from_path::<ConvertedJsonList>(path)
38+
import_from_path::<ConvertedJsonList>(path)
39+
} else if backup_type.otp_uri {
40+
import_from_path::<OtpUriList>(path)
3641
} else {
37-
return Err(String::from("Invalid arguments provided"));
42+
return Err(eyre!("Invalid arguments provided"));
3843
};
3944

40-
let elements = result.map_err(|e| format!("An error occurred: {e}"))?;
45+
let elements = result.map_err(|e| eyre!("{e}"))?;
4146

4247
database.add_all(elements);
4348
Ok(database)
4449
}
4550

46-
pub fn add(matches: AddArgs, mut database: OTPDatabase) -> Result<OTPDatabase, String> {
51+
pub fn add(matches: AddArgs, mut database: OTPDatabase) -> color_eyre::Result<OTPDatabase> {
4752
let otp_element = if matches.otp_uri {
4853
let mut otp_uri = rpassword::prompt_password("Insert the otp uri: ").unwrap();
4954
let result = OTPElement::from_otp_uri(otp_uri.as_str());
@@ -53,24 +58,23 @@ pub fn add(matches: AddArgs, mut database: OTPDatabase) -> Result<OTPDatabase, S
5358
get_from_args(matches)?
5459
};
5560
if !otp_element.valid_secret() {
56-
return Err(String::from("Invalid secret."));
61+
return Err(ErrReport::msg("Invalid secret."));
5762
}
5863

5964
database.add_element(otp_element);
6065
Ok(database)
6166
}
6267

63-
fn get_from_args(matches: AddArgs) -> Result<OTPElement, String> {
64-
let secret = rpassword::prompt_password("Insert the secret: ")
65-
.map_err(|e| format!("Error during password insertion: {:?}", e))?;
68+
fn get_from_args(matches: AddArgs) -> color_eyre::Result<OTPElement> {
69+
let secret = rpassword::prompt_password("Insert the secret: ").map_err(ErrReport::from)?;
6670
Ok(map_args_to_code(secret, matches))
6771
}
6872

6973
fn map_args_to_code(secret: String, matches: AddArgs) -> OTPElement {
7074
OTPElement {
7175
secret,
72-
issuer: matches.issuer.unwrap(),
73-
label: matches.label,
76+
issuer: matches.issuer,
77+
label: matches.label.unwrap(),
7478
digits: matches.digits,
7579
type_: matches.otp_type,
7680
algorithm: matches.algorithm,
@@ -80,7 +84,7 @@ fn map_args_to_code(secret: String, matches: AddArgs) -> OTPElement {
8084
}
8185
}
8286

83-
pub fn edit(matches: EditArgs, mut database: OTPDatabase) -> Result<OTPDatabase, String> {
87+
pub fn edit(matches: EditArgs, mut database: OTPDatabase) -> color_eyre::Result<OTPDatabase> {
8488
let secret = matches
8589
.change_secret
8690
.then(|| rpassword::prompt_password("Insert the secret: ").unwrap());
@@ -90,7 +94,7 @@ pub fn edit(matches: EditArgs, mut database: OTPDatabase) -> Result<OTPDatabase,
9094

9195
if let Some(real_index) = index.checked_sub(1) {
9296
if real_index >= database.elements_ref().len() {
93-
return Err(format!("{index} is an invalid index"));
97+
return Err(eyre!("{index} is an invalid index"));
9498
}
9599

96100
match database.mut_element(real_index) {
@@ -121,15 +125,15 @@ pub fn edit(matches: EditArgs, mut database: OTPDatabase) -> Result<OTPDatabase,
121125
}
122126
database.mark_modified();
123127
}
124-
None => return Err(format!("No element found at index {index}")),
128+
None => return Err(eyre!("No element found at index {index}")),
125129
}
126130
Ok(database)
127131
} else {
128-
Err(format! {"{index} is an invalid index"})
132+
Err(eyre!("{index} is an invalid index"))
129133
}
130134
}
131135

132-
pub fn export(matches: ExportArgs, database: OTPDatabase) -> Result<OTPDatabase, String> {
136+
pub fn export(matches: ExportArgs, database: OTPDatabase) -> color_eyre::Result<OTPDatabase> {
133137
let export_format = matches.format.unwrap_or_default();
134138
let exported_path = if matches.path.is_dir() {
135139
matches.path.join("exported.cotp")
@@ -142,6 +146,9 @@ pub fn export(matches: ExportArgs, database: OTPDatabase) -> Result<OTPDatabase,
142146
} else if export_format.andotp {
143147
let andotp: &Vec<OTPElement> = (&database).into();
144148
do_export(&andotp, exported_path)
149+
} else if export_format.otp_uri {
150+
let otp_uri_list: OtpUriList = (&database).into();
151+
do_export(&otp_uri_list, exported_path)
145152
} else {
146153
unreachable!("Unreachable code");
147154
}
@@ -152,14 +159,14 @@ pub fn export(matches: ExportArgs, database: OTPDatabase) -> Result<OTPDatabase,
152159
);
153160
database
154161
})
155-
.map_err(|e| format!("An error occurred while exporting database: {e}"))
162+
.map_err(|e| eyre!("An error occurred while exporting database: {e}"))
156163
}
157164

158-
pub fn change_password(mut database: OTPDatabase) -> Result<OTPDatabase, String> {
165+
pub fn change_password(mut database: OTPDatabase) -> color_eyre::Result<OTPDatabase> {
159166
let mut new_password = utils::verified_password("New password: ", 8);
160167
database
161168
.save_with_pw(&new_password)
162-
.map_err(|e| format!("An error has occurred: {e}"))?;
169+
.map_err(ErrReport::from)?;
163170
new_password.zeroize();
164171
Ok(database)
165172
}

src/crypto/cryptography.rs

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use argon2::{Config, Variant, Version};
22
use chacha20poly1305::aead::Aead;
33
use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
4+
use color_eyre::eyre::{eyre, ErrReport};
45
use data_encoding::BASE64;
56

67
use super::encrypted_database::EncryptedDatabase;
@@ -19,40 +20,33 @@ const KEY_DERIVATION_CONFIG: Config = Config {
1920
hash_length: XCHACHA20_POLY1305_KEY_LENGTH as u32,
2021
};
2122

22-
pub fn argon_derive_key(password_bytes: &[u8], salt: &[u8]) -> Result<Vec<u8>, String> {
23-
let config = KEY_DERIVATION_CONFIG;
24-
let hash = argon2::hash_raw(password_bytes, salt, &config);
25-
match hash {
26-
Ok(vec) => Ok(vec),
27-
Err(_e) => Err(String::from("Failed to derive encryption key")),
28-
}
23+
pub fn argon_derive_key(password_bytes: &[u8], salt: &[u8]) -> color_eyre::Result<Vec<u8>> {
24+
argon2::hash_raw(password_bytes, salt, &KEY_DERIVATION_CONFIG).map_err(ErrReport::from)
2925
}
3026

31-
pub fn gen_salt() -> Result<[u8; ARGON2ID_SALT_LENGTH], String> {
27+
pub fn gen_salt() -> color_eyre::Result<[u8; ARGON2ID_SALT_LENGTH]> {
3228
let mut salt: [u8; ARGON2ID_SALT_LENGTH] = [0; ARGON2ID_SALT_LENGTH];
33-
if let Err(e) = getrandom::getrandom(&mut salt) {
34-
return Err(format!("Error during salt generation: {e}"));
35-
}
29+
getrandom::getrandom(&mut salt).map_err(ErrReport::from)?;
3630
Ok(salt)
3731
}
3832

3933
pub fn encrypt_string_with_key(
4034
plain_text: String,
4135
key: &Vec<u8>,
4236
salt: &[u8],
43-
) -> Result<EncryptedDatabase, String> {
37+
) -> color_eyre::Result<EncryptedDatabase> {
4438
let wrapped_key = Key::from_slice(key.as_slice());
4539

4640
let aead = XChaCha20Poly1305::new(wrapped_key);
4741
let mut nonce_bytes: [u8; XCHACHA20_POLY1305_NONCE_LENGTH] =
4842
[0; XCHACHA20_POLY1305_NONCE_LENGTH];
49-
if let Err(e) = getrandom::getrandom(&mut nonce_bytes) {
50-
return Err(format!("Error during nonce generation: {e}"));
51-
}
43+
44+
getrandom::getrandom(&mut nonce_bytes).map_err(ErrReport::from)?;
45+
5246
let nonce = XNonce::from_slice(&nonce_bytes);
5347
let cipher_text = aead
5448
.encrypt(nonce, plain_text.as_bytes())
55-
.expect("Failed to encrypt");
49+
.map_err(|e| eyre!("Error during encryption: {e}"))?;
5650
Ok(EncryptedDatabase::new(
5751
1,
5852
BASE64.encode(&nonce_bytes),
@@ -64,10 +58,10 @@ pub fn encrypt_string_with_key(
6458
pub fn decrypt_string(
6559
encrypted_text: &str,
6660
password: &str,
67-
) -> Result<(String, Vec<u8>, Vec<u8>), String> {
61+
) -> color_eyre::Result<(String, Vec<u8>, Vec<u8>)> {
6862
//encrypted text is an encrypted database json serialized object
6963
let encrypted_database: EncryptedDatabase = serde_json::from_str(encrypted_text)
70-
.map_err(|e| format!("Error during encrypted database deserialization: {e}"))?;
64+
.map_err(|e| eyre!("Error during encrypted database deserialization: {e}"))?;
7165
let nonce = BASE64
7266
.decode(encrypted_database.nonce().as_bytes())
7367
.expect("Cannot decode Base64 nonce");
@@ -84,11 +78,9 @@ pub fn decrypt_string(
8478
let nonce = XNonce::from_slice(nonce.as_slice());
8579
let decrypted = aead
8680
.decrypt(nonce, cipher_text.as_slice())
87-
.map_err(|_| String::from("Wrong password"))?;
88-
match String::from_utf8(decrypted) {
89-
Ok(result) => Ok((result, key, salt)),
90-
Err(e) => Err(format!("Error during UTF-8 string conversion: {e}")),
91-
}
81+
.map_err(|_| eyre!("Wrong password"))?;
82+
let from_utf8 = String::from_utf8(decrypted).map_err(ErrReport::from)?;
83+
Ok((from_utf8, key, salt))
9284
}
9385

9486
#[cfg(test)]

src/exporters/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use serde::Serialize;
44
use zeroize::Zeroize;
55

66
pub mod andotp;
7+
pub mod otp_uri;
78

89
pub fn do_export<T>(to_be_saved: &T, exported_path: PathBuf) -> Result<PathBuf, String>
910
where

src/exporters/otp_uri.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
use crate::otp::otp_element::OTPDatabase;
2+
use serde::{Deserialize, Serialize};
3+
4+
#[derive(Serialize, Deserialize)]
5+
pub struct OtpUriList {
6+
pub items: Vec<String>,
7+
}
8+
9+
impl<'a> From<&'a OTPDatabase> for OtpUriList {
10+
fn from(value: &'a OTPDatabase) -> Self {
11+
let items: Vec<String> = value.elements.iter().map(|e| e.get_otpauth_uri()).collect();
12+
13+
OtpUriList { items }
14+
}
15+
}

src/importers/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ pub mod authy_remote_debug;
44
pub mod converted;
55
pub mod freeotp_plus;
66
pub mod importer;
7+
pub mod otp_uri;

0 commit comments

Comments
 (0)