Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support import / export of OTP Uri bulk #317

Merged
merged 28 commits into from
Oct 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
141f068
Add export from otp uri flag
replydev Sep 7, 2023
063f42c
Add OTP Uri import flag in arguments
replydev Sep 15, 2023
39e994b
Implement importer from OTP Uri batch
replydev Sep 15, 2023
f3b667d
Implement exporter from OTP Uri batch
replydev Sep 15, 2023
5d4d1b6
Import from OTP Uri flag
replydev Sep 15, 2023
2aab1d6
Add otp uri arg default for ExportFormat
replydev Sep 15, 2023
2eb98ca
Derive Deserialize for OtpUriList
replydev Sep 15, 2023
8a18b54
Implement try_from as requested from importer implementation
replydev Sep 15, 2023
41d2cfa
Append commit hash to app title if we are building in debug mode
replydev Sep 15, 2023
31436fe
Handle OTP uri import error using FromIterator trait
replydev Sep 15, 2023
4cc4b6a
Do not duplicate "An Error Occurred String"
replydev Sep 15, 2023
50a6512
Wire new otp_uri exporter
replydev Sep 15, 2023
cc78dab
Change implementation to build OtpUriList from OTPDatabase reference
replydev Sep 15, 2023
65d1081
Align OTP Uri import / export flags
replydev Sep 17, 2023
9ebfb5e
Fix OTP Uri export with no issuer
replydev Sep 28, 2023
53ca529
Decode issuer and label from Base64
replydev Oct 8, 2023
b86e807
Optimize in_ssh_shell function
replydev Oct 8, 2023
bdb8bdc
Refactor OTP Uri parsing using url crate
replydev Oct 9, 2023
f008016
Install color_eyre into main method
replydev Oct 9, 2023
bc3a7f9
Add OTP uri deserialization unit test
replydev Oct 9, 2023
3f428aa
Fix clippy warnings
replydev Oct 9, 2023
5e177b6
Migrate to color_eyre for error handling
replydev Oct 9, 2023
d33e8e0
Fix test
replydev Oct 9, 2023
6e0bb06
Change error description on no label found
replydev Oct 9, 2023
6970ec5
Fix clippy warnings
replydev Oct 9, 2023
0bda85f
Make the label required instead of issuer
replydev Oct 10, 2023
f5102cc
Do not use default issuer
replydev Oct 10, 2023
27165c4
Merge branch 'master' into feat/otp_uri_export
replydev Oct 10, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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