Skip to content

Commit

Permalink
Support import / export of OTP Uri bulk (#317)
Browse files Browse the repository at this point in the history
This PR implements import / export logic from a bulk of OTP Uris.
Closes #296
Closes #263
  • Loading branch information
replydev authored Oct 10, 2023
2 parents 6228967 + 27165c4 commit ce603f2
Show file tree
Hide file tree
Showing 15 changed files with 505 additions and 137 deletions.
259 changes: 259 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,5 @@ base64 = "0.21.4"
md-5 = "0.10.6"
ratatui = { version = "0.23.0", features = ["all-widgets"] }
crossterm = "0.27.0"
url = "2.4.1"
color-eyre = "0.6.2"
21 changes: 20 additions & 1 deletion build.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::env;
use std::process::Command;

fn main() {
let version = env!("CARGO_PKG_VERSION");
Expand All @@ -7,6 +8,24 @@ fn main() {
println!("cargo:rustc-env=COTP_VERSION={}", version);
} else {
// Suffix with -DEBUG
println!("cargo:rustc-env=COTP_VERSION={}-DEBUG", version);
// If we can get the last commit hash, let's append that also
if let Some(last_commit) = get_last_commit() {
println!(
"cargo:rustc-env=COTP_VERSION={}-DEBUG-{}",
version, last_commit
);
} else {
println!("cargo:rustc-env=COTP_VERSION={}-DEBUG", version);
}
}
}

fn get_last_commit() -> Option<String> {
Command::new("git")
.args(["rev-parse", "--short=12", "HEAD"])
.output()
.ok()
.filter(|e| e.status.success())
.map(|e| String::from_utf8(e.stdout))
.and_then(|e| e.ok())
}
26 changes: 18 additions & 8 deletions src/args.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::path::PathBuf;

use clap::{Args, Parser, Subcommand};
use color_eyre::eyre::eyre;

use crate::{
argument_functions, dashboard,
Expand Down Expand Up @@ -31,20 +32,20 @@ enum CotpSubcommands {
#[derive(Args)]
pub struct AddArgs {
/// Add OTP code via an OTP URI
#[arg(short = 'u', long = "otpuri", required_unless_present = "issuer")]
#[arg(short = 'u', long = "otpuri", required_unless_present = "label")]
pub otp_uri: bool,

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

/// Code issuer
#[arg(short, long, required_unless_present = "otp_uri")]
pub issuer: Option<String>,
#[arg(short, long, default_value = "")]
pub issuer: String,

/// Code label
#[arg(short, long, default_value = "")]
pub label: String,
#[arg(short, long, required_unless_present = "otp_uri")]
pub label: Option<String>,

/// OTP Algorithm
#[arg(short, long, value_enum, default_value_t = OTPAlgorithm::Sha1)]
Expand Down Expand Up @@ -179,6 +180,10 @@ pub struct BackupType {
/// Import from Microsoft Authenticator
#[arg(short = 'm', long = "microsoft-authenticator")]
pub microsoft_authenticator: bool,

/// Import from OTP Uri batch
#[arg(short, long = "otp-uri")]
pub otp_uri: bool,
}

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

/// Import from andOTP backup
/// Export into andOTP backup
#[arg(short = 'e', long)]
pub andotp: bool,

/// Export into an OTP URI
#[arg(short, long = "otp-uri")]
pub otp_uri: bool,
}

impl Default for ExportFormat {
fn default() -> Self {
Self {
cotp: true,
andotp: false,
otp_uri: false,
}
}
}

pub fn args_parser(matches: CotpArgs, read_result: OTPDatabase) -> Result<OTPDatabase, String> {
pub fn args_parser(matches: CotpArgs, read_result: OTPDatabase) -> color_eyre::Result<OTPDatabase> {
match matches.command {
Some(CotpSubcommands::Add(args)) => argument_functions::add(args, read_result),
Some(CotpSubcommands::Edit(args)) => argument_functions::edit(args, read_result),
Some(CotpSubcommands::Import(args)) => argument_functions::import(args, read_result),
Some(CotpSubcommands::Export(args)) => argument_functions::export(args, read_result),
Some(CotpSubcommands::Passwd) => argument_functions::change_password(read_result),
// no args, show dashboard
None => dashboard(read_result).map_err(|e| format!("{:?}", e)),
None => dashboard(read_result).map_err(|e| eyre!("An error occurred: {e}")),
}
}

Expand Down
59 changes: 33 additions & 26 deletions src/argument_functions.rs
Original file line number Diff line number Diff line change
@@ -1,49 +1,54 @@
use crate::args::{AddArgs, EditArgs, ExportArgs, ImportArgs};
use crate::exporters::do_export;
use crate::exporters::otp_uri::OtpUriList;
use crate::importers::aegis::AegisJson;
use crate::importers::aegis_encrypted::AegisEncryptedDatabase;
use crate::importers::authy_remote_debug::AuthyExportedList;
use crate::importers::converted::ConvertedJsonList;
use crate::importers::freeotp_plus::FreeOTPPlusJson;
use crate::importers::importer::import_from_path;
use crate::otp::from_otp_uri::FromOtpUri;
use crate::otp::otp_element::{OTPDatabase, OTPElement};
use crate::{importers, utils};
use crate::utils;
use color_eyre::eyre::{eyre, ErrReport};
use zeroize::Zeroize;

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

let backup_type = matches.backup_type;

let result = if backup_type.cotp {
importers::importer::import_from_path::<OTPDatabase>(path)
import_from_path::<OTPDatabase>(path)
} else if backup_type.andotp {
importers::importer::import_from_path::<Vec<OTPElement>>(path)
import_from_path::<Vec<OTPElement>>(path)
} else if backup_type.aegis {
importers::importer::import_from_path::<AegisJson>(path)
import_from_path::<AegisJson>(path)
} else if backup_type.aegis_encrypted {
importers::importer::import_from_path::<AegisEncryptedDatabase>(path)
import_from_path::<AegisEncryptedDatabase>(path)
} else if backup_type.freeotp_plus {
importers::importer::import_from_path::<FreeOTPPlusJson>(path)
import_from_path::<FreeOTPPlusJson>(path)
} else if backup_type.authy_exported {
importers::importer::import_from_path::<AuthyExportedList>(path)
import_from_path::<AuthyExportedList>(path)
} else if backup_type.google_authenticator
|| backup_type.authy
|| backup_type.microsoft_authenticator
|| backup_type.freeotp
{
importers::importer::import_from_path::<ConvertedJsonList>(path)
import_from_path::<ConvertedJsonList>(path)
} else if backup_type.otp_uri {
import_from_path::<OtpUriList>(path)
} else {
return Err(String::from("Invalid arguments provided"));
return Err(eyre!("Invalid arguments provided"));
};

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

database.add_all(elements);
Ok(database)
}

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

database.add_element(otp_element);
Ok(database)
}

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

fn map_args_to_code(secret: String, matches: AddArgs) -> OTPElement {
OTPElement {
secret,
issuer: matches.issuer.unwrap(),
label: matches.label,
issuer: matches.issuer,
label: matches.label.unwrap(),
digits: matches.digits,
type_: matches.otp_type,
algorithm: matches.algorithm,
Expand All @@ -80,7 +84,7 @@ fn map_args_to_code(secret: String, matches: AddArgs) -> OTPElement {
}
}

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

if let Some(real_index) = index.checked_sub(1) {
if real_index >= database.elements_ref().len() {
return Err(format!("{index} is an invalid index"));
return Err(eyre!("{index} is an invalid index"));
}

match database.mut_element(real_index) {
Expand Down Expand Up @@ -121,15 +125,15 @@ pub fn edit(matches: EditArgs, mut database: OTPDatabase) -> Result<OTPDatabase,
}
database.mark_modified();
}
None => return Err(format!("No element found at index {index}")),
None => return Err(eyre!("No element found at index {index}")),
}
Ok(database)
} else {
Err(format! {"{index} is an invalid index"})
Err(eyre!("{index} is an invalid index"))
}
}

pub fn export(matches: ExportArgs, database: OTPDatabase) -> Result<OTPDatabase, String> {
pub fn export(matches: ExportArgs, database: OTPDatabase) -> color_eyre::Result<OTPDatabase> {
let export_format = matches.format.unwrap_or_default();
let exported_path = if matches.path.is_dir() {
matches.path.join("exported.cotp")
Expand All @@ -142,6 +146,9 @@ pub fn export(matches: ExportArgs, database: OTPDatabase) -> Result<OTPDatabase,
} else if export_format.andotp {
let andotp: &Vec<OTPElement> = (&database).into();
do_export(&andotp, exported_path)
} else if export_format.otp_uri {
let otp_uri_list: OtpUriList = (&database).into();
do_export(&otp_uri_list, exported_path)
} else {
unreachable!("Unreachable code");
}
Expand All @@ -152,14 +159,14 @@ pub fn export(matches: ExportArgs, database: OTPDatabase) -> Result<OTPDatabase,
);
database
})
.map_err(|e| format!("An error occurred while exporting database: {e}"))
.map_err(|e| eyre!("An error occurred while exporting database: {e}"))
}

pub fn change_password(mut database: OTPDatabase) -> Result<OTPDatabase, String> {
pub fn change_password(mut database: OTPDatabase) -> color_eyre::Result<OTPDatabase> {
let mut new_password = utils::verified_password("New password: ", 8);
database
.save_with_pw(&new_password)
.map_err(|e| format!("An error has occurred: {e}"))?;
.map_err(ErrReport::from)?;
new_password.zeroize();
Ok(database)
}
38 changes: 15 additions & 23 deletions src/crypto/cryptography.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use argon2::{Config, Variant, Version};
use chacha20poly1305::aead::Aead;
use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
use color_eyre::eyre::{eyre, ErrReport};
use data_encoding::BASE64;

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

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

pub fn gen_salt() -> Result<[u8; ARGON2ID_SALT_LENGTH], String> {
pub fn gen_salt() -> color_eyre::Result<[u8; ARGON2ID_SALT_LENGTH]> {
let mut salt: [u8; ARGON2ID_SALT_LENGTH] = [0; ARGON2ID_SALT_LENGTH];
if let Err(e) = getrandom::getrandom(&mut salt) {
return Err(format!("Error during salt generation: {e}"));
}
getrandom::getrandom(&mut salt).map_err(ErrReport::from)?;
Ok(salt)
}

pub fn encrypt_string_with_key(
plain_text: String,
key: &Vec<u8>,
salt: &[u8],
) -> Result<EncryptedDatabase, String> {
) -> color_eyre::Result<EncryptedDatabase> {
let wrapped_key = Key::from_slice(key.as_slice());

let aead = XChaCha20Poly1305::new(wrapped_key);
let mut nonce_bytes: [u8; XCHACHA20_POLY1305_NONCE_LENGTH] =
[0; XCHACHA20_POLY1305_NONCE_LENGTH];
if let Err(e) = getrandom::getrandom(&mut nonce_bytes) {
return Err(format!("Error during nonce generation: {e}"));
}

getrandom::getrandom(&mut nonce_bytes).map_err(ErrReport::from)?;

let nonce = XNonce::from_slice(&nonce_bytes);
let cipher_text = aead
.encrypt(nonce, plain_text.as_bytes())
.expect("Failed to encrypt");
.map_err(|e| eyre!("Error during encryption: {e}"))?;
Ok(EncryptedDatabase::new(
1,
BASE64.encode(&nonce_bytes),
Expand All @@ -64,10 +58,10 @@ pub fn encrypt_string_with_key(
pub fn decrypt_string(
encrypted_text: &str,
password: &str,
) -> Result<(String, Vec<u8>, Vec<u8>), String> {
) -> color_eyre::Result<(String, Vec<u8>, Vec<u8>)> {
//encrypted text is an encrypted database json serialized object
let encrypted_database: EncryptedDatabase = serde_json::from_str(encrypted_text)
.map_err(|e| format!("Error during encrypted database deserialization: {e}"))?;
.map_err(|e| eyre!("Error during encrypted database deserialization: {e}"))?;
let nonce = BASE64
.decode(encrypted_database.nonce().as_bytes())
.expect("Cannot decode Base64 nonce");
Expand All @@ -84,11 +78,9 @@ pub fn decrypt_string(
let nonce = XNonce::from_slice(nonce.as_slice());
let decrypted = aead
.decrypt(nonce, cipher_text.as_slice())
.map_err(|_| String::from("Wrong password"))?;
match String::from_utf8(decrypted) {
Ok(result) => Ok((result, key, salt)),
Err(e) => Err(format!("Error during UTF-8 string conversion: {e}")),
}
.map_err(|_| eyre!("Wrong password"))?;
let from_utf8 = String::from_utf8(decrypted).map_err(ErrReport::from)?;
Ok((from_utf8, key, salt))
}

#[cfg(test)]
Expand Down
1 change: 1 addition & 0 deletions src/exporters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use serde::Serialize;
use zeroize::Zeroize;

pub mod andotp;
pub mod otp_uri;

pub fn do_export<T>(to_be_saved: &T, exported_path: PathBuf) -> Result<PathBuf, String>
where
Expand Down
15 changes: 15 additions & 0 deletions src/exporters/otp_uri.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
use crate::otp::otp_element::OTPDatabase;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct OtpUriList {
pub items: Vec<String>,
}

impl<'a> From<&'a OTPDatabase> for OtpUriList {
fn from(value: &'a OTPDatabase) -> Self {
let items: Vec<String> = value.elements.iter().map(|e| e.get_otpauth_uri()).collect();

OtpUriList { items }
}
}
1 change: 1 addition & 0 deletions src/importers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ pub mod authy_remote_debug;
pub mod converted;
pub mod freeotp_plus;
pub mod importer;
pub mod otp_uri;
Loading

0 comments on commit ce603f2

Please sign in to comment.