feat: Support TOTP #45

Merged
awiteb merged 5 commits from awiteb/fix-35 into master 2024-05-12 08:13:56 +02:00 AGit
14 changed files with 241 additions and 37 deletions

47
Cargo.lock generated
View file

@ -106,6 +106,12 @@ dependencies = [
"rustc-demangle", "rustc-demangle",
] ]
[[package]]
name = "base32"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@ -329,6 +335,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@ -531,6 +538,15 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "http" name = "http"
version = "0.2.12" version = "0.2.12"
@ -725,6 +741,7 @@ name = "lprs"
version = "1.2.1" version = "1.2.1"
dependencies = [ dependencies = [
"aes", "aes",
"base32",
"base64 0.22.1", "base64 0.22.1",
"bincode", "bincode",
"cbc", "cbc",
@ -742,6 +759,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"thiserror", "thiserror",
"totp-lite",
] ]
[[package]] [[package]]
@ -1165,6 +1183,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.8" version = "0.10.8"
@ -1243,6 +1272,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.60" version = "2.0.60"
@ -1374,6 +1409,18 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "totp-lite"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8e43134db17199f7f721803383ac5854edd0d3d523cc34dba321d6acfbe76c3"
dependencies = [
"digest",
"hmac",
"sha1",
"sha2",
]
[[package]] [[package]]
name = "tower-service" name = "tower-service"
version = "0.3.2" version = "0.3.2"

View file

@ -30,6 +30,8 @@ sha2 = "0.10.8"
serde_json = "1.0.116" serde_json = "1.0.116"
base64 = "0.22.1" base64 = "0.22.1"
clap_complete = "4.5.2" clap_complete = "4.5.2"
totp-lite = "2.0.1"
base32 = "0.4.0"
[features] [features]
default = ["update-notify"] default = ["update-notify"]

View file

@ -31,10 +31,14 @@ use crate::{
pub struct Add { pub struct Add {
#[command(flatten)] #[command(flatten)]
vault_info: Vault, vault_info: Vault,
/// The password, if there is no value for it you will prompt it /// The password, if there is no value you will prompt it
#[arg(short, long)] #[arg(short, long)]
#[allow(clippy::option_option)] #[allow(clippy::option_option)]
password: Option<Option<String>>, password: Option<Option<String>>,
/// The TOTP secret, if there is no value you will prompt it
#[arg(short, long)]
#[allow(clippy::option_option)]
totp_secret: Option<Option<String>>,
/// Add a custom field to the vault /// Add a custom field to the vault
#[arg(name = "KEY=VALUE", short = 'c', long = "custom")] #[arg(name = "KEY=VALUE", short = 'c', long = "custom")]
#[arg(value_parser = clap_parsers::kv_parser)] #[arg(value_parser = clap_parsers::kv_parser)]
@ -51,7 +55,8 @@ impl LprsCommand for Add {
fn run(mut self, mut vault_manager: Vaults) -> LprsResult<()> { fn run(mut self, mut vault_manager: Vaults) -> LprsResult<()> {
if !self.vault_info.is_empty() { if !self.vault_info.is_empty() {
self.vault_info.name = self.vault_info.name.trim().to_string(); self.vault_info.name = self.vault_info.name.trim().to_string();
self.vault_info.password = utils::user_password(self.password, "Vault password:")?; self.vault_info.password = utils::user_secret(self.password, "Vault password:")?;
self.vault_info.totp_secret = utils::user_secret(self.totp_secret, "TOTP Secret:")?;
self.vault_info.custom_fields = self.custom_fields.into_iter().collect(); self.vault_info.custom_fields = self.custom_fields.into_iter().collect();
vault_manager.add_vault(self.vault_info); vault_manager.add_vault(self.vault_info);
vault_manager.try_export()?; vault_manager.try_export()?;
@ -71,6 +76,13 @@ impl LprsCommand for Add {
))); )));
} }
} }
if self
.custom_fields
.iter()
.any(|(k, _)| k.starts_with(crate::RESERVED_FIELD_PREFIX))
{
return Err(LprsError::ReservedPrefix(crate::RESERVED_FIELD_PREFIX));
}
Ok(()) Ok(())
} }

View file

@ -42,12 +42,16 @@ pub struct Edit {
#[arg(short = 'o', long)] #[arg(short = 'o', long)]
/// The new vault note /// The new vault note
note: Option<String>, note: Option<String>,
/// The TOTP secret, if there is no value you will prompt it
#[arg(short, long)]
#[allow(clippy::option_option)]
totp_secret: Option<Option<String>>,
/// The custom field, make its value empty to delete it /// The custom field, make its value empty to delete it
/// ///
/// If the custom field not exist will created it, if it's will update it /// If the custom field not exist will created it, if it's will update it
#[arg(name = "KEY=VALUE", short = 'c', long = "custom")] #[arg(name = "KEY=VALUE", short = 'c', long = "custom")]
#[arg(value_parser = clap_parsers::kv_parser)] #[arg(value_parser = clap_parsers::kv_parser)]
pub custom_fields: Vec<(String, String)>, custom_fields: Vec<(String, String)>,
/// Force edit, will not return error if there is a problem with the args. /// Force edit, will not return error if there is a problem with the args.
/// ///
/// For example, duplication in the custom fields and try to editing nothing /// For example, duplication in the custom fields and try to editing nothing
@ -73,7 +77,10 @@ impl LprsCommand for Edit {
vault.name = new_name; vault.name = new_name;
} }
if self.password.is_some() { if self.password.is_some() {
vault.password = utils::user_password(self.password, "New vault password:")?; vault.password = utils::user_secret(self.password, "New vault password:")?;
}
if self.totp_secret.is_some() {
vault.totp_secret = utils::user_secret(self.totp_secret, "TOTP Secret:")?;
} }
if let Some(new_username) = self.username { if let Some(new_username) = self.username {
vault.username = Some(new_username); vault.username = Some(new_username);
@ -109,6 +116,13 @@ impl LprsCommand for Edit {
))); )));
} }
} }
if self
.custom_fields
.iter()
.any(|(k, _)| k.starts_with(crate::RESERVED_FIELD_PREFIX))
{
return Err(LprsError::ReservedPrefix(crate::RESERVED_FIELD_PREFIX));
}
Ok(()) Ok(())
} }

View file

@ -55,7 +55,7 @@ impl LprsCommand for Export {
); );
let encryption_key: Option<[u8; 32]> = let encryption_key: Option<[u8; 32]> =
utils::user_password(self.encryption_password, "Encryption Password:")? utils::user_secret(self.encryption_password, "Encryption Password:")?
.map(|p| sha2::Sha256::digest(p).into()); .map(|p| sha2::Sha256::digest(p).into());
let exported_data = match self.format { let exported_data = match self.format {

View file

@ -20,7 +20,7 @@ use clap::Args;
use crate::{ use crate::{
utils, utils,
vault::{Vault, Vaults}, vault::{cipher, Vault, Vaults},
LprsCommand, LprsCommand,
LprsError, LprsError,
LprsResult, LprsResult,
@ -34,6 +34,8 @@ enum VaultGetField {
Password, Password,
Service, Service,
Note, Note,
TotpSecret,
TotpCode,
Custom(String), Custom(String),
} }
@ -48,6 +50,8 @@ impl FromStr for VaultGetField {
"password" => Self::Password, "password" => Self::Password,
"service" => Self::Service, "service" => Self::Service,
"note" => Self::Note, "note" => Self::Note,
"totp_secret" => Self::TotpSecret,
"totp_code" => Self::TotpCode,
_ => Self::Custom(input.to_owned()), _ => Self::Custom(input.to_owned()),
}) })
} }
@ -63,6 +67,8 @@ impl VaultGetField {
Self::Password => vault.password.as_deref(), Self::Password => vault.password.as_deref(),
Self::Service => vault.service.as_deref(), Self::Service => vault.service.as_deref(),
Self::Note => vault.note.as_deref(), Self::Note => vault.note.as_deref(),
Self::TotpSecret => vault.totp_secret.as_deref(),
Self::TotpCode => None,
Self::Custom(custom_field) => vault.custom_fields.get(custom_field).map(|x| x.as_str()), Self::Custom(custom_field) => vault.custom_fields.get(custom_field).map(|x| x.as_str()),
} }
} }
@ -76,6 +82,8 @@ impl VaultGetField {
Self::Password => "password", Self::Password => "password",
Self::Service => "service", Self::Service => "service",
Self::Note => "note", Self::Note => "note",
Self::TotpSecret => "totp_secret",
Self::TotpCode => "totp_code",
Self::Custom(field) => field.as_str(), Self::Custom(field) => field.as_str(),
} }
} }
@ -90,8 +98,10 @@ pub struct Get {
location: String, location: String,
/// A Specific field to get. /// A Specific field to get.
/// ///
/// Can be [name,username,password,service,note,"string"] where the string /// Can be [name, username, password, service, note, totp_secret, totp_code,
/// means a custom field /// "string"]
///
/// where the string means a custom field
#[arg(value_parser = VaultGetField::from_str)] #[arg(value_parser = VaultGetField::from_str)]
field: Option<VaultGetField>, field: Option<VaultGetField>,
} }
@ -106,13 +116,25 @@ impl LprsCommand for Get {
print!("{index}"); print!("{index}");
return Ok(()); return Ok(());
} }
if field == VaultGetField::TotpCode {
if let Some(ref totp_secret) = vault.totp_secret {
let totp_code = cipher::totp_now(totp_secret, &vault.totp_hash)?.0;
print!("{totp_code}");
return Ok(());
} else {
return Err(LprsError::Other(
"There is no TOTP secret to get TOTP code".to_owned(),
));
}
}
if let Some(value) = field.get_from_vault(vault) { if let Some(value) = field.get_from_vault(vault) {
print!("{value}") print!("{value}")
} else { } else {
return Err(LprsError::Other(format!( return Err(LprsError::Other(format!(
"There is no value for `{}`", "There is no value for `{}` at \"{}\" vault",
field.as_str() field.as_str(),
vault.name
))); )));
} }
} else { } else {

View file

@ -60,7 +60,7 @@ impl LprsCommand for Import {
); );
let decryption_key: Option<[u8; 32]> = let decryption_key: Option<[u8; 32]> =
utils::user_password(self.decryption_password, "Decryption password:")? utils::user_secret(self.decryption_password, "Decryption password:")?
.map(|p| sha2::Sha256::digest(p).into()); .map(|p| sha2::Sha256::digest(p).into());
let imported_passwords_len = match self.format { let imported_passwords_len = match self.format {
@ -71,6 +71,14 @@ impl LprsCommand for Import {
.unwrap_or(&vault_manager.master_password), .unwrap_or(&vault_manager.master_password),
&fs::read(self.path)?, &fs::read(self.path)?,
)?; )?;
if vaults.iter().any(|v| {
v.custom_fields
.iter()
.any(|(k, _)| k.starts_with(crate::RESERVED_FIELD_PREFIX))
}) {
return Err(LprsError::ReservedPrefix(crate::RESERVED_FIELD_PREFIX));
}
let vaults_len = vaults.len(); let vaults_len = vaults.len();
vault_manager.vaults.extend(vaults); vault_manager.vaults.extend(vaults);

View file

@ -17,7 +17,13 @@
use clap::Args; use clap::Args;
use inquire::{InquireError, Select}; use inquire::{InquireError, Select};
use crate::{vault::Vaults, LprsCommand, LprsError, LprsResult}; use crate::{
vault::{cipher, Vaults},
LprsCommand,
LprsError,
LprsResult,
RESERVED_FIELD_PREFIX,
};
#[derive(Debug, Args)] #[derive(Debug, Args)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
@ -35,7 +41,7 @@ pub struct List {
} }
impl LprsCommand for List { impl LprsCommand for List {
fn run(self, vault_manager: Vaults) -> LprsResult<()> { fn run(self, mut vault_manager: Vaults) -> LprsResult<()> {
if vault_manager.vaults.is_empty() { if vault_manager.vaults.is_empty() {
return Err(LprsError::Other( return Err(LprsError::Other(
"Looks like there is no vaults to list".to_owned(), "Looks like there is no vaults to list".to_owned(),
@ -95,15 +101,22 @@ impl LprsCommand for List {
log::debug!("The user selected the vault at index: {vault_idx}"); log::debug!("The user selected the vault at index: {vault_idx}");
println!( let vault = vault_manager
"{}",
vault_manager
.vaults .vaults
.get(vault_idx - 1) .get_mut(vault_idx - 1)
.expect("The index is correct") .expect("The index is correct");
if let Some(ref totp_secret) = vault.totp_secret {
let (code, remaining) = cipher::totp_now(totp_secret, &vault.totp_hash)?;
vault.custom_fields.insert(
format!("{RESERVED_FIELD_PREFIX}TOTP Code"),
format!("{code} ({remaining}s remaining)"),
); );
} }
println!("{vault}",);
}
Ok(()) Ok(())
} }

View file

@ -39,6 +39,13 @@ pub enum Error {
InvalidVaultIndex(String), InvalidVaultIndex(String),
#[error("{0}")] #[error("{0}")]
ArgParse(String), ArgParse(String),
#[error(
"Reserved Prefix Error: Sorry, but the following prefix is reserved and cannot be used in \
custom fields {0}"
)]
ReservedPrefix(&'static str),
#[error("Base32 Error: {0}")]
Base32(String),
#[error("{0}")] #[error("{0}")]
Other(String), Other(String),

View file

@ -48,6 +48,8 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg(feature = "update-notify")] #[cfg(feature = "update-notify")]
/// The last version check file. Used to store the last version check time. /// The last version check file. Used to store the last version check time.
pub const LAST_VERSION_CHECK_FILE: &str = ".last_version_check"; pub const LAST_VERSION_CHECK_FILE: &str = ".last_version_check";
/// The prefix of the reserved custom fields
const RESERVED_FIELD_PREFIX: &str = ".lprsfield.";
fn main() -> ExitCode { fn main() -> ExitCode {
let lprs_cli = cli::Cli::parse(); let lprs_cli = cli::Cli::parse();

View file

@ -45,24 +45,24 @@ pub fn local_project_file(filename: &str) -> LprsResult<PathBuf> {
Ok(local_dir.join(filename)) Ok(local_dir.join(filename))
} }
/// Returns the user password if any /// Returns the user secret if any
/// ///
/// - If the `password` is `None` will return `None` /// - If the `secret` is `None` will return `None`
/// - If the `password` is `Some(None)` will ask the user for a password in the /// - If the `secret` is `Some(None)` will ask the user for a secret in the
/// stdin and return it /// stdin and return it
/// - If the `password` is `Some(Some(password))` will return `Some(password)` /// - If the `secret` is `Some(Some(secret))` will return `Some(secret)`
/// ///
/// ## Errors /// ## Errors
/// - When failed to get the password from stdin /// - When failed to get the secret from stdin
pub fn user_password( pub fn user_secret(
password: Option<Option<String>>, secret: Option<Option<String>>,
prompt_message: &str, prompt_message: &str,
) -> LprsResult<Option<String>> { ) -> LprsResult<Option<String>> {
Ok(match password { Ok(match secret {
None => None, None => None,
Some(Some(p)) => Some(p), Some(Some(p)) => Some(p),
Some(None) => { Some(None) => {
log::debug!("User didn't provide a password, prompting it"); log::debug!("User didn't provide a secret, prompting it");
Some( Some(
Password::new(prompt_message) Password::new(prompt_message)
.without_confirmation() .without_confirmation()

View file

@ -20,7 +20,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::{Vault, Vaults}; use super::{cipher::TotpHash, Vault, Vaults};
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BitWardenLoginData { pub struct BitWardenLoginData {
@ -84,6 +84,8 @@ impl From<BitWardenPassword> for Vault {
.into_iter() .into_iter()
.map(|nv| (nv.name, nv.value)) .map(|nv| (nv.name, nv.value))
.collect(), .collect(),
None::<String>,
TotpHash::default(),
) )
} }
} }

View file

@ -17,13 +17,63 @@
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use base32::Alphabet as Base32Alphabet;
use clap::ValueEnum;
use rand::{rngs::StdRng, Rng, SeedableRng}; use rand::{rngs::StdRng, Rng, SeedableRng};
use serde::{Deserialize, Serialize};
use crate::{LprsError, LprsResult}; use crate::{LprsError, LprsResult};
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>; type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>; type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
#[derive(Default, Clone, Debug, ValueEnum, Eq, PartialEq, Deserialize, Serialize)]
/// The TOTP hash functions
pub enum TotpHash {
/// Sha1 hash function
#[default]
Sha1,
/// Sha256 hash function
Sha256,
/// Sha512 hash function
Sha512,
}
/// Create the TOTP code of the current time
///
/// ## Errors
/// - If the given `secret_base32` are vaild base32
pub fn totp_now(secret_base32: &str, hash_function: &TotpHash) -> LprsResult<(String, u8)> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("SystemTime before UNIX EPOCH!")
.as_secs();
let remaining = 30 - (now % 30) as u8;
let secret = base32::decode(Base32Alphabet::RFC4648 { padding: true }, secret_base32)
.ok_or_else(|| LprsError::Base32("Can't decode the TOTP secret".to_owned()))?;
Ok(match hash_function {
TotpHash::Sha1 => {
(
totp_lite::totp_custom::<totp_lite::Sha1>(30, 6, &secret, now),
remaining,
)
}
TotpHash::Sha256 => {
(
totp_lite::totp_custom::<totp_lite::Sha256>(30, 6, &secret, now),
remaining,
)
}
TotpHash::Sha512 => {
(
totp_lite::totp_custom::<totp_lite::Sha512>(30, 6, &secret, now),
remaining,
)
}
})
}
/// Encrypt the given data by the given key using AES-256 CBC /// Encrypt the given data by the given key using AES-256 CBC
/// ///
/// Note: The IV will be add it to the end of the ciphertext (Last 16 bytes) /// Note: The IV will be add it to the end of the ciphertext (Last 16 bytes)

View file

@ -29,6 +29,8 @@ mod bitwarden;
pub use bitwarden::*; pub use bitwarden::*;
use self::cipher::TotpHash;
#[derive(Clone, Debug, ValueEnum, Eq, PartialEq)] #[derive(Clone, Debug, ValueEnum, Eq, PartialEq)]
/// The vaults format /// The vaults format
pub enum Format { pub enum Format {
@ -61,6 +63,12 @@ pub struct Vault {
/// The vault custom fields /// The vault custom fields
#[arg(skip)] #[arg(skip)]
pub custom_fields: BTreeMap<String, String>, pub custom_fields: BTreeMap<String, String>,
/// The TOTP secret
#[arg(skip)]
pub totp_secret: Option<String>,
/// The TOTP hash function
#[arg(long, value_name = "HASH_FUNCTION", value_enum, default_value_t)]
pub totp_hash: TotpHash,
} }
/// The vaults manager /// The vaults manager
@ -76,6 +84,7 @@ pub struct Vaults {
impl Vault { impl Vault {
/// Create new [`Vault`] instance /// Create new [`Vault`] instance
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
name: impl Into<String>, name: impl Into<String>,
username: Option<impl Into<String>>, username: Option<impl Into<String>>,
@ -83,6 +92,8 @@ impl Vault {
service: Option<impl Into<String>>, service: Option<impl Into<String>>,
note: Option<impl Into<String>>, note: Option<impl Into<String>>,
custom_fields: BTreeMap<String, String>, custom_fields: BTreeMap<String, String>,
totp_secret: Option<impl Into<String>>,
totp_hash: TotpHash,
) -> Self { ) -> Self {
Self { Self {
name: name.into(), name: name.into(),
@ -91,6 +102,8 @@ impl Vault {
service: service.map(Into::into), service: service.map(Into::into),
note: note.map(Into::into), note: note.map(Into::into),
custom_fields, custom_fields,
totp_secret: totp_secret.map(Into::into),
totp_hash,
} }
} }
@ -163,6 +176,8 @@ impl Vaults {
.iter() .iter()
.map(|(key, value)| (encrypt(key), encrypt(value))) .map(|(key, value)| (encrypt(key), encrypt(value)))
.collect(), .collect(),
v.totp_secret.as_ref().map(|t| encrypt(t)),
v.totp_hash.clone(),
)) ))
}) })
.collect::<LprsResult<Vec<_>>>()?, .collect::<LprsResult<Vec<_>>>()?,
@ -199,6 +214,8 @@ impl Vaults {
.into_iter() .into_iter()
.map(|(key, value)| LprsResult::Ok((decrypt(&key)?, decrypt(&value)?))) .map(|(key, value)| LprsResult::Ok((decrypt(&key)?, decrypt(&value)?)))
.collect::<LprsResult<_>>()?, .collect::<LprsResult<_>>()?,
v.totp_secret.as_ref().and_then(|t| decrypt(t).ok()),
v.totp_hash,
)) ))
}) })
.collect() .collect()
@ -266,8 +283,16 @@ impl fmt::Display for Vault {
if let Some(ref note) = self.note { if let Some(ref note) = self.note {
write!(f, "\nNote:\n{note}")?; write!(f, "\nNote:\n{note}")?;
} }
if let Some(ref totp_secret) = self.totp_secret {
write!(f, "\nTOTP Secret: {totp_secret}")?;
}
for (key, value) in &self.custom_fields { for (key, value) in &self.custom_fields {
write!(f, "\n{key}: {value}")?; write!(
f,
"\n{}: {value}",
key.strip_prefix(crate::RESERVED_FIELD_PREFIX)
.unwrap_or(key)
)?;
} }
Ok(()) Ok(())