//! `pass-otp` compatible URI handling, code generation, and repository mutation. use std::{error::Error, fmt, ops::Range, str, sync::Mutex}; use data_encoding::BASE32_NOPAD; use hmac::Hmac; use sha1::Sha1; use sha2::{Sha256, Sha512}; use zeroize::Zeroize as _; use crate::{ command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest}, crypto::{CryptoError, KeyStore, SecretProvider}, git::{AutomaticEntryCommitter, GitError, GitIdentity}, recipient::{RecipientPolicyError, RecipientPolicyManager, SigningPolicy}, repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes}, write::{EntryAction, EntryCommit, EntryCommitError, EntryCommitter, OverwriteDecision}, }; const DEFAULT_DIGITS: u32 = 6; const DEFAULT_PERIOD: u64 = 30; static OTP_MUTATION_LOCK: Mutex<()> = Mutex::new(()); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OtpKind { Totp, Hotp, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OtpAlgorithm { Sha1, Sha256, Sha512, } /// A validated key URI whose encoded and decoded secrets zeroize on drop. pub struct OtpUri { encoded: SecretBytes, kind: OtpKind, secret: SecretBytes, issuer: Option, account: String, algorithm: OtpAlgorithm, digits: u32, period: Option, counter: Option, counter_value: Option>, } impl OtpUri { pub fn parse(encoded: SecretBytes) -> Result { let text = str::from_utf8(encoded.expose()).map_err(|_| OtpError::InvalidUri)?; if text.is_empty() || text .bytes() .any(|byte| byte.is_ascii_control() || byte == b' ') { return Err(OtpError::InvalidUri); } let remainder = text .strip_prefix("otpauth://") .ok_or(OtpError::InvalidScheme)?; let (authority_and_label, query) = remainder .split_once('?') .ok_or(OtpError::MissingParameters)?; if query.is_empty() || query.contains('#') { return Err(OtpError::MissingParameters); } let (kind_text, raw_label) = authority_and_label .split_once('/') .ok_or(OtpError::MissingAccount)?; let kind = match kind_text { "totp" => OtpKind::Totp, "hotp" => OtpKind::Hotp, _ => return Err(OtpError::UnsupportedType), }; if raw_label.is_empty() || raw_label.contains('/') { return Err(OtpError::MissingAccount); } let label = decode_component(raw_label)?; let (label_issuer, account) = split_label(&label)?; let query_offset = text.len() - query.len(); let mut secret = None; let mut issuer = None; let mut algorithm = None; let mut digits = None; let mut period = None; let mut counter = None; let mut counter_value = None; let mut offset = 0; for parameter in query.split('&') { if parameter.is_empty() { return Err(OtpError::InvalidParameter); } let (name, raw_value) = parameter .split_once('=') .ok_or(OtpError::InvalidParameter)?; if name.is_empty() || raw_value.is_empty() { return Err(OtpError::InvalidParameter); } match name { "secret" => { set_once(&mut secret, decode_secret(raw_value)?, OtpParameter::Secret)?; } "issuer" => { let value = decode_component(raw_value)?; if value.is_empty() || value.contains(':') { return Err(OtpError::InvalidIssuer); } set_once(&mut issuer, value, OtpParameter::Issuer)?; } "algorithm" => { let value = decode_ascii(raw_value)?; let value = match value.to_ascii_uppercase().as_str() { "SHA1" => OtpAlgorithm::Sha1, "SHA256" => OtpAlgorithm::Sha256, "SHA512" => OtpAlgorithm::Sha512, _ => return Err(OtpError::InvalidAlgorithm), }; set_once(&mut algorithm, value, OtpParameter::Algorithm)?; } "digits" => { let value = parse_number(raw_value).ok_or(OtpError::InvalidDigits)?; if !matches!(value, 6 | 8) { return Err(OtpError::InvalidDigits); } set_once(&mut digits, value as u32, OtpParameter::Digits)?; } "period" => { let value = parse_number(raw_value).ok_or(OtpError::InvalidPeriod)?; if value == 0 { return Err(OtpError::InvalidPeriod); } set_once(&mut period, value, OtpParameter::Period)?; } "counter" => { let value = parse_number(raw_value).ok_or(OtpError::InvalidCounter)?; set_once(&mut counter, value, OtpParameter::Counter)?; let start = query_offset + offset + name.len() + 1; counter_value = Some(start..start + raw_value.len()); } _ => {} } offset += parameter.len() + 1; } let secret = secret.ok_or(OtpError::MissingSecret)?; if let (Some(label), Some(parameter)) = (label_issuer.as_ref(), issuer.as_ref()) && label != parameter { return Err(OtpError::IssuerMismatch); } let issuer = issuer.or(label_issuer); let algorithm = algorithm.unwrap_or(OtpAlgorithm::Sha1); let digits = digits.unwrap_or(DEFAULT_DIGITS); let (period, counter, counter_value) = match kind { OtpKind::Totp => { if counter.is_some() { return Err(OtpError::UnexpectedCounter); } (Some(period.unwrap_or(DEFAULT_PERIOD)), None, None) } OtpKind::Hotp => { if period.is_some() { return Err(OtpError::UnexpectedPeriod); } ( None, Some(counter.ok_or(OtpError::MissingCounter)?), counter_value, ) } }; Ok(Self { encoded, kind, secret, issuer, account, algorithm, digits, period, counter, counter_value, }) } pub fn parse_str(encoded: &str) -> Result { Self::parse(SecretBytes::new(encoded.as_bytes().to_vec())) } pub fn from_input(source: &OtpInputSource, input: OtpInput) -> Result { match source { OtpInputSource::Uri => Self::parse(input.into_secret()), OtpInputSource::Secret { issuer, account } => { build_secret_uri(input.into_secret(), issuer.as_deref(), account.as_deref()) } } } pub fn encoded(&self) -> &SecretBytes { &self.encoded } pub fn kind(&self) -> OtpKind { self.kind } pub fn issuer(&self) -> Option<&str> { self.issuer.as_deref() } pub fn account(&self) -> &str { &self.account } pub fn algorithm(&self) -> OtpAlgorithm { self.algorithm } pub fn digits(&self) -> u32 { self.digits } pub fn period(&self) -> Option { self.period } pub fn counter(&self) -> Option { self.counter } pub fn derived_entry(&self) -> Result { let path = self.issuer.as_ref().map_or_else( || self.account.clone(), |issuer| format!("{issuer}/{}", self.account), ); EntryPath::parse(&path).map_err(Into::into) } pub fn code_at(&self, unix_seconds: u64) -> Result { let period = self.period.ok_or(OtpError::NotTotp)?; self.code_for_counter(unix_seconds / period) } pub fn code_for_counter(&self, counter: u64) -> Result { let message = counter.to_be_bytes(); let mut digest = match self.algorithm { OtpAlgorithm::Sha1 => hmac_digest::>(self.secret.expose(), &message)?, OtpAlgorithm::Sha256 => hmac_digest::>(self.secret.expose(), &message)?, OtpAlgorithm::Sha512 => hmac_digest::>(self.secret.expose(), &message)?, }; let offset = usize::from(digest[digest.len() - 1] & 0x0f); let binary = (u32::from(digest[offset]) & 0x7f) << 24 | u32::from(digest[offset + 1]) << 16 | u32::from(digest[offset + 2]) << 8 | u32::from(digest[offset + 3]); digest.zeroize(); let modulus = 10_u32.pow(self.digits); let code = format!("{:0width$}", binary % modulus, width = self.digits as usize); Ok(SecretBytes::new(code.into_bytes())) } fn incremented_hotp(&self) -> Result<(u64, Self), OtpError> { let counter = self.counter.ok_or(OtpError::NotHotp)?; let incremented = counter.checked_add(1).ok_or(OtpError::CounterOverflow)?; let range = self.counter_value.clone().ok_or(OtpError::MissingCounter)?; let mut encoded = Vec::with_capacity(self.encoded.expose().len() + 1); encoded.extend_from_slice(&self.encoded.expose()[..range.start]); encoded.extend_from_slice(incremented.to_string().as_bytes()); encoded.extend_from_slice(&self.encoded.expose()[range.end..]); Ok((incremented, Self::parse(SecretBytes::new(encoded))?)) } } impl fmt::Debug for OtpUri { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("OtpUri") .field("encoded", &"[REDACTED]") .field("kind", &self.kind) .field("secret", &"[REDACTED]") .field("issuer", &self.issuer) .field("account", &self.account) .field("algorithm", &self.algorithm) .field("digits", &self.digits) .field("period", &self.period) .field("counter", &self.counter) .finish() } } pub struct OtpInput { secret: SecretBytes, } impl OtpInput { pub fn hidden(mut first: Vec, mut confirmation: Vec) -> Result { if let Err(error) = validate_input(&first) { first.zeroize(); confirmation.zeroize(); return Err(error); } if let Err(error) = validate_input(&confirmation) { first.zeroize(); confirmation.zeroize(); return Err(error); } if first != confirmation { first.zeroize(); confirmation.zeroize(); return Err(OtpError::ConfirmationMismatch); } confirmation.zeroize(); Ok(Self { secret: SecretBytes::new(first), }) } pub fn line(mut line: Vec) -> Result { if let Err(error) = validate_input(&line) { line.zeroize(); return Err(error); } Ok(Self { secret: SecretBytes::new(line), }) } fn into_secret(self) -> SecretBytes { self.secret } } impl fmt::Debug for OtpInput { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("OtpInput([REDACTED])") } } pub struct OtpInsertPlan { path: EntryPath, uri: OtpUri, original: Option, force: bool, confirm_path: bool, } impl OtpInsertPlan { pub fn path(&self) -> &EntryPath { &self.path } pub fn requires_path_confirmation(&self) -> bool { self.confirm_path } pub fn requires_overwrite_confirmation(&self) -> bool { self.original.is_some() && !self.force } } impl fmt::Debug for OtpInsertPlan { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("OtpInsertPlan") .field("path", &self.path) .field("uri", &self.uri) .field("has_original", &self.original.is_some()) .field("force", &self.force) .field("confirm_path", &self.confirm_path) .finish() } } pub struct OtpAppendSession { path: EntryPath, original: EncryptedEntry, plaintext: SecretBytes, existing: Option>, source: OtpInputSource, force: bool, } impl OtpAppendSession { pub fn path(&self) -> &EntryPath { &self.path } pub fn requires_replace_confirmation(&self) -> bool { self.existing.is_some() && !self.force } } impl fmt::Debug for OtpAppendSession { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("OtpAppendSession") .field("path", &self.path) .field("plaintext", &"[REDACTED]") .field("has_uri", &self.existing.is_some()) .field("source", &self.source) .field("force", &self.force) .finish() } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct OtpWriteOutcome { path: EntryPath, replaced: bool, } impl OtpWriteOutcome { pub fn path(&self) -> &EntryPath { &self.path } pub fn replaced(&self) -> bool { self.replaced } } pub struct OtpCodeOutcome { code: SecretBytes, validity: OtpCodeValidity, } /// Storage-owned validity information for presenting an OTP code. /// /// Frontends use this value instead of deriving TOTP periods or inferring HOTP /// behavior from display strings. A timed code carries its exclusive Unix-time /// boundary and complete period for progress presentation, while a /// counter-based code identifies the committed HOTP counter. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OtpCodeValidity { Timed { valid_until: u64, period: u64 }, CounterBased { counter: u64 }, } impl OtpCodeValidity { pub fn valid_until(self) -> Option { match self { Self::Timed { valid_until, .. } => Some(valid_until), Self::CounterBased { .. } => None, } } pub fn counter(self) -> Option { match self { Self::Timed { .. } => None, Self::CounterBased { counter } => Some(counter), } } pub fn period(self) -> Option { match self { Self::Timed { period, .. } => Some(period), Self::CounterBased { .. } => None, } } pub fn remaining_at(self, unix_seconds: u64) -> Option { self.valid_until() .map(|valid_until| valid_until.saturating_sub(unix_seconds)) } } impl OtpCodeOutcome { pub fn code(&self) -> &SecretBytes { &self.code } pub fn counter(&self) -> Option { self.validity.counter() } pub fn valid_until(&self) -> Option { self.validity.valid_until() } pub fn validity(&self) -> OtpCodeValidity { self.validity } pub fn remaining_at(&self, unix_seconds: u64) -> Option { self.validity.remaining_at(unix_seconds) } } impl fmt::Debug for OtpCodeOutcome { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("OtpCodeOutcome") .field("code", &"[REDACTED]") .field("validity", &self.validity) .finish() } } pub struct OtpService<'a> { repository: &'a Repository, keys: &'a KeyStore, } impl<'a> OtpService<'a> { pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self { Self { repository, keys } } pub fn validate(encoded: &str) -> Result<(), OtpError> { OtpUri::parse_str(encoded).map(|_| ()) } pub fn validate_input(input: OtpInput) -> Result<(), OtpError> { OtpUri::parse(input.into_secret()).map(|_| ()) } pub fn prepare_insert( &self, request: &OtpInsertRequest, input: OtpInput, ) -> Result { let uri = OtpUri::from_input(&request.source, input)?; let confirm_path = request.entry.is_none(); let path = request .entry .as_deref() .map(parse_entry) .transpose()? .map_or_else(|| uri.derived_entry(), Ok)?; let original = match self.repository.read_entry(&path) { Ok(original) => Some(original), Err(RepositoryError::NotFound { .. }) => None, Err(error) => return Err(error.into()), }; Ok(OtpInsertPlan { path, uri, original, force: request.force, confirm_path, }) } #[allow(clippy::too_many_arguments)] pub fn finish_insert( &self, plan: OtpInsertPlan, path_decision: OverwriteDecision, overwrite: OverwriteDecision, signing: Option<&SigningPolicy>, committer: &mut impl EntryCommitter, ) -> Result { if plan.confirm_path && path_decision == OverwriteDecision::Decline || plan.original.is_some() && !plan.force && overwrite == OverwriteDecision::Decline { return Err(OtpError::Cancelled); } let mut plaintext = Vec::with_capacity(plan.uri.encoded().expose().len() + 1); plaintext.extend_from_slice(plan.uri.encoded().expose()); plaintext.push(b'\n'); let replaced = plan.original.is_some(); self.store( &plan.path, SecretBytes::new(plaintext), plan.original.as_ref(), EntryAction::Insert, format!("Add OTP secret for {} to store.", plan.path), signing, committer, )?; Ok(OtpWriteOutcome { path: plan.path, replaced, }) } pub fn begin_append( &self, request: &OtpAppendRequest, provider: &mut impl SecretProvider, ) -> Result { let path = parse_entry(&request.entry)?; let original = self.repository.read_entry(&path)?; let plaintext = self.keys.decrypt(&original, provider)?; let existing = find_uri(&plaintext, &path)?.map(|(range, _)| range); Ok(OtpAppendSession { path, original, plaintext, existing, source: request.source.clone(), force: request.force, }) } pub fn finish_append( &self, session: OtpAppendSession, input: OtpInput, replace: OverwriteDecision, signing: Option<&SigningPolicy>, committer: &mut impl EntryCommitter, ) -> Result { if session.existing.is_some() && !session.force && replace == OverwriteDecision::Decline { return Err(OtpError::Cancelled); } let uri = OtpUri::from_input(&session.source, input)?; let (replacement, replaced, message) = if let Some(range) = &session.existing { let mut replacement = Vec::with_capacity( session.plaintext.expose().len() - range.len() + uri.encoded().expose().len(), ); replacement.extend_from_slice(&session.plaintext.expose()[..range.start]); replacement.extend_from_slice(uri.encoded().expose()); replacement.extend_from_slice(&session.plaintext.expose()[range.end..]); ( replacement, true, format!("Replace OTP secret for {}.", session.path), ) } else { let mut replacement = Vec::with_capacity( session.plaintext.expose().len() + uri.encoded().expose().len() + 2, ); replacement.extend_from_slice(session.plaintext.expose()); if !replacement.is_empty() && !replacement.ends_with(b"\n") { replacement.push(b'\n'); } replacement.extend_from_slice(uri.encoded().expose()); replacement.push(b'\n'); ( replacement, false, format!("Append OTP secret for {}.", session.path), ) }; self.store( &session.path, SecretBytes::new(replacement), Some(&session.original), EntryAction::Edit, message, signing, committer, )?; Ok(OtpWriteOutcome { path: session.path, replaced, }) } pub fn uri(&self, entry: &str, provider: &mut impl SecretProvider) -> Result { let path = parse_entry(entry)?; let ciphertext = self.repository.read_entry(&path)?; let plaintext = self.keys.decrypt(&ciphertext, provider)?; find_uri(&plaintext, &path)? .map(|(_, uri)| uri) .ok_or(OtpError::MissingUri { entry: path }) } pub fn code( &self, entry: &str, unix_seconds: u64, signing: Option<&SigningPolicy>, provider: &mut impl SecretProvider, committer: &mut impl EntryCommitter, ) -> Result { let (path, original, plaintext, range, uri) = self.load_code_entry(entry, provider)?; self.finish_code( path, original, plaintext, range, uri, unix_seconds, signing, committer, ) } /// Generate a code with storage-owned lazy Git selection. TOTP is /// read-only and never opens Git; HOTP opens the innermost repository only /// after the token has been decrypted and identified as counter based. pub fn code_automatic( &self, entry: &str, unix_seconds: u64, signing: Option<&SigningPolicy>, provider: &mut impl SecretProvider, ) -> Result { let (path, original, plaintext, range, uri) = self.load_code_entry(entry, provider)?; if uri.kind() == OtpKind::Totp { let period = uri.period().ok_or(OtpError::NotTotp)?; return Ok(OtpCodeOutcome { code: uri.code_at(unix_seconds)?, validity: OtpCodeValidity::Timed { valid_until: (unix_seconds / period) .checked_add(1) .and_then(|counter| counter.checked_mul(period)) .ok_or(OtpError::CounterOverflow)?, period, }, }); } let entry = path.to_string(); let mut committer = AutomaticEntryCommitter::for_entry( self.repository, &entry, GitIdentity::ironstorage(), )?; self.finish_code( path, original, plaintext, range, uri, unix_seconds, signing, &mut committer, ) } fn load_code_entry( &self, entry: &str, provider: &mut impl SecretProvider, ) -> Result<(EntryPath, EncryptedEntry, SecretBytes, Range, OtpUri), OtpError> { let path = parse_entry(entry)?; let original = self.repository.read_entry(&path)?; let plaintext = self.keys.decrypt(&original, provider)?; let (range, uri) = find_uri(&plaintext, &path)?.ok_or_else(|| OtpError::MissingUri { entry: path.clone(), })?; Ok((path, original, plaintext, range, uri)) } #[allow(clippy::too_many_arguments)] fn finish_code( &self, path: EntryPath, original: EncryptedEntry, plaintext: SecretBytes, range: Range, uri: OtpUri, unix_seconds: u64, signing: Option<&SigningPolicy>, committer: &mut impl EntryCommitter, ) -> Result { match uri.kind() { OtpKind::Totp => { let period = uri.period().ok_or(OtpError::NotTotp)?; Ok(OtpCodeOutcome { code: uri.code_at(unix_seconds)?, validity: OtpCodeValidity::Timed { valid_until: { (unix_seconds / period) .checked_add(1) .and_then(|counter| counter.checked_mul(period)) .ok_or(OtpError::CounterOverflow)? }, period, }, }) } OtpKind::Hotp => { let (counter, incremented) = uri.incremented_hotp()?; let code = incremented.code_for_counter(counter)?; let mut replacement = Vec::with_capacity( plaintext.expose().len() - range.len() + incremented.encoded().expose().len(), ); replacement.extend_from_slice(&plaintext.expose()[..range.start]); replacement.extend_from_slice(incremented.encoded().expose()); replacement.extend_from_slice(&plaintext.expose()[range.end..]); self.store( &path, SecretBytes::new(replacement), Some(&original), EntryAction::Edit, format!("Increment HOTP counter for {path}."), signing, committer, )?; Ok(OtpCodeOutcome { code, validity: OtpCodeValidity::CounterBased { counter }, }) } } } #[allow(clippy::too_many_arguments)] fn store( &self, path: &EntryPath, plaintext: SecretBytes, original: Option<&EncryptedEntry>, action: EntryAction, message: String, signing: Option<&SigningPolicy>, committer: &mut impl EntryCommitter, ) -> Result<(), OtpError> { let _mutation = OTP_MUTATION_LOCK .lock() .map_err(|_| OtpError::MutationLockUnavailable)?; let current = match self.repository.read_entry(path) { Ok(current) => Some(current), Err(RepositoryError::NotFound { .. }) => None, Err(error) => return Err(error.into()), }; if current.as_ref() != original { return Err(OtpError::ConcurrentModification { entry: path.clone(), }); } let recipients = RecipientPolicyManager::new(self.repository, self.keys) .resolve_for_entry(path, signing)?; let ciphertext = self.keys.encrypt(plaintext, recipients.recipients())?; self.repository.write_entry(path, &ciphertext)?; let change = EntryCommit::new(path.clone(), action, message); if let Err(operation) = committer.commit(&change) { if let Err(rollback) = self.restore(path, original) { return Err(OtpError::RollbackFailed { operation, rollback, }); } return Err(OtpError::Commit(operation)); } Ok(()) } fn restore( &self, path: &EntryPath, original: Option<&EncryptedEntry>, ) -> Result<(), RepositoryError> { if let Some(original) = original { self.repository.write_entry(path, original) } else { self.repository.remove_entry(path)?; self.repository .cleanup_empty_directories(&path.parent_directory()) .map(|_| ()) } } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OtpParameter { Secret, Issuer, Algorithm, Digits, Period, Counter, } #[derive(Debug)] pub enum OtpError { Repository(RepositoryError), Crypto(CryptoError), RecipientPolicy(RecipientPolicyError), Git(GitError), InvalidUri, InvalidScheme, UnsupportedType, MissingParameters, MissingAccount, InvalidLabel, InvalidIssuer, MissingSecret, InvalidSecret, InvalidParameter, DuplicateParameter(OtpParameter), IssuerMismatch, InvalidAlgorithm, InvalidDigits, InvalidPeriod, InvalidCounter, MissingCounter, UnexpectedCounter, UnexpectedPeriod, NotTotp, NotHotp, CounterOverflow, EmptyInput, InvalidInput, ConfirmationMismatch, MissingUri { entry: EntryPath, }, AmbiguousUri { entry: EntryPath, }, Cancelled, MutationLockUnavailable, ConcurrentModification { entry: EntryPath, }, Commit(EntryCommitError), RollbackFailed { operation: EntryCommitError, rollback: RepositoryError, }, } impl fmt::Display for OtpError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Repository(error) => error.fmt(formatter), Self::Crypto(error) => error.fmt(formatter), Self::RecipientPolicy(error) => error.fmt(formatter), Self::Git(error) => error.fmt(formatter), Self::InvalidUri => formatter.write_str("OTP key URI is not valid UTF-8 URI text"), Self::InvalidScheme => formatter.write_str("OTP key URI must use the otpauth scheme"), Self::UnsupportedType => formatter.write_str("OTP key URI type must be totp or hotp"), Self::MissingParameters => formatter.write_str("OTP key URI parameters are missing"), Self::MissingAccount => formatter.write_str("OTP key URI account is missing"), Self::InvalidLabel => formatter.write_str("OTP key URI label is invalid"), Self::InvalidIssuer => formatter.write_str("OTP key URI issuer is invalid"), Self::MissingSecret => formatter.write_str("OTP key URI secret is missing"), Self::InvalidSecret => formatter.write_str("OTP key URI secret is not valid Base32"), Self::InvalidParameter => formatter.write_str("OTP key URI parameter is invalid"), Self::DuplicateParameter(parameter) => { write!( formatter, "OTP key URI has a duplicate {parameter:?} parameter" ) } Self::IssuerMismatch => { formatter.write_str("OTP key URI label and parameter issuers do not match") } Self::InvalidAlgorithm => formatter.write_str("OTP algorithm is invalid"), Self::InvalidDigits => formatter.write_str("OTP digit count must be 6 or 8"), Self::InvalidPeriod => formatter.write_str("TOTP period must be a positive integer"), Self::InvalidCounter => formatter.write_str("HOTP counter must be an integer"), Self::MissingCounter => formatter.write_str("HOTP counter is missing"), Self::UnexpectedCounter => formatter.write_str("TOTP URI cannot contain a counter"), Self::UnexpectedPeriod => formatter.write_str("HOTP URI cannot contain a period"), Self::NotTotp => formatter.write_str("the OTP token is not time based"), Self::NotHotp => formatter.write_str("the OTP token is not counter based"), Self::CounterOverflow => formatter.write_str("HOTP counter cannot be incremented"), Self::EmptyInput => formatter.write_str("OTP input may not be empty"), Self::InvalidInput => formatter.write_str("OTP input must be a single line"), Self::ConfirmationMismatch => { formatter.write_str("OTP input confirmation does not match") } Self::MissingUri { entry } => write!(formatter, "OTP key URI not found in {entry}"), Self::AmbiguousUri { entry } => { write!(formatter, "multiple OTP key URIs found in {entry}") } Self::Cancelled => formatter.write_str("OTP mutation was declined"), Self::MutationLockUnavailable => { formatter.write_str("OTP mutation serialization is unavailable") } Self::ConcurrentModification { entry } => { write!(formatter, "OTP entry changed concurrently: {entry}") } Self::Commit(error) => write!(formatter, "cannot commit OTP mutation: {error}"), Self::RollbackFailed { operation, rollback, } => write!( formatter, "OTP commit failed ({operation}) and repository rollback failed ({rollback})" ), } } } impl Error for OtpError {} impl From for OtpError { fn from(error: RepositoryError) -> Self { Self::Repository(error) } } impl From for OtpError { fn from(error: CryptoError) -> Self { Self::Crypto(error) } } impl From for OtpError { fn from(error: RecipientPolicyError) -> Self { Self::RecipientPolicy(error) } } impl From for OtpError { fn from(error: GitError) -> Self { Self::Git(error) } } fn validate_input(input: &[u8]) -> Result<(), OtpError> { if input.is_empty() { Err(OtpError::EmptyInput) } else if input.iter().any(|byte| matches!(byte, b'\n' | b'\r')) { Err(OtpError::InvalidInput) } else { Ok(()) } } fn parse_entry(input: &str) -> Result { EntryPath::parse(input.trim_end_matches('/')).map_err(Into::into) } fn set_once( destination: &mut Option, value: T, parameter: OtpParameter, ) -> Result<(), OtpError> { if destination.replace(value).is_some() { Err(OtpError::DuplicateParameter(parameter)) } else { Ok(()) } } fn split_label(label: &str) -> Result<(Option, String), OtpError> { if label.is_empty() || label.chars().any(char::is_control) { return Err(OtpError::InvalidLabel); } if let Some((issuer, account)) = label.split_once(':') { let account = account.trim_start_matches(' '); if issuer.is_empty() || account.is_empty() || account.contains(':') { return Err(OtpError::InvalidLabel); } Ok((Some(issuer.to_owned()), account.to_owned())) } else { Ok((None, label.to_owned())) } } fn decode_ascii(value: &str) -> Result { let decoded = decode_component(value)?; if decoded.is_ascii() { Ok(decoded) } else { Err(OtpError::InvalidParameter) } } fn decode_component(value: &str) -> Result { let bytes = decode_component_bytes(value)?; let decoded = String::from_utf8(bytes).map_err(|_| OtpError::InvalidParameter)?; if decoded.chars().any(char::is_control) { Err(OtpError::InvalidParameter) } else { Ok(decoded) } } fn decode_component_bytes(value: &str) -> Result, OtpError> { let bytes = value.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; while index < bytes.len() { match bytes[index] { b'%' => { let high = bytes.get(index + 1).and_then(|byte| hex(*byte)); let low = bytes.get(index + 2).and_then(|byte| hex(*byte)); let (Some(high), Some(low)) = (high, low) else { decoded.zeroize(); return Err(OtpError::InvalidParameter); }; decoded.push(high << 4 | low); index += 3; } b'+' => { decoded.push(b' '); index += 1; } byte => { decoded.push(byte); index += 1; } } } Ok(decoded) } fn hex(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), b'a'..=b'f' => Some(byte - b'a' + 10), b'A'..=b'F' => Some(byte - b'A' + 10), _ => None, } } fn decode_secret(raw: &str) -> Result { let mut encoded = decode_component_bytes(raw)?; for byte in &mut encoded { byte.make_ascii_uppercase(); } if let Some(padding_start) = encoded.iter().position(|byte| *byte == b'=') { let padding = encoded.len() - padding_start; let expected = match padding_start % 8 { 2 => 6, 4 => 4, 5 => 3, 7 => 1, _ => 0, }; if encoded[padding_start..].iter().any(|byte| *byte != b'=') || encoded.len() % 8 != 0 || padding != expected { encoded.zeroize(); return Err(OtpError::InvalidSecret); } encoded.truncate(padding_start); } if encoded.is_empty() { encoded.zeroize(); return Err(OtpError::InvalidSecret); } let decoded = BASE32_NOPAD .decode(&encoded) .map_err(|_| OtpError::InvalidSecret); encoded.zeroize(); let decoded = decoded?; if decoded.is_empty() { Err(OtpError::InvalidSecret) } else { Ok(SecretBytes::new(decoded)) } } fn parse_number(value: &str) -> Option { if value.bytes().all(|byte| byte.is_ascii_digit()) { value.parse().ok() } else { None } } fn build_secret_uri( secret: SecretBytes, issuer: Option<&str>, account: Option<&str>, ) -> Result { let secret_text = str::from_utf8(secret.expose()).map_err(|_| OtpError::InvalidSecret)?; decode_secret(secret_text)?; let issuer = issuer.filter(|value| !value.is_empty()); let account = account.filter(|value| !value.is_empty()); if issuer.is_none() && account.is_none() { return Err(OtpError::MissingAccount); } let encoded_issuer = issuer.map(percent_encode); let encoded_account = account.map(percent_encode); let label = match (&encoded_issuer, &encoded_account) { (Some(issuer), Some(account)) => format!("{issuer}:{account}"), (Some(issuer), None) => issuer.clone(), (None, Some(account)) => account.clone(), (None, None) => unreachable!("an issuer or account was required"), }; let mut uri = format!("otpauth://totp/{label}?secret={secret_text}"); if let Some(issuer) = encoded_issuer { uri.push_str("&issuer="); uri.push_str(&issuer); } OtpUri::parse(SecretBytes::new(uri.into_bytes())) } fn percent_encode(value: &str) -> String { let mut encoded = String::new(); for byte in value.as_bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'~' | b'_' | b'-' => { encoded.push(char::from(*byte)); } b' ' => encoded.push('+'), byte => { use fmt::Write as _; write!(encoded, "%{byte:02X}").expect("writing to String cannot fail"); } } } encoded } fn hmac_digest(key: &[u8], message: &[u8]) -> Result, OtpError> where M: hmac::digest::Mac + hmac::digest::KeyInit, { let mut mac = ::new_from_slice(key).map_err(|_| OtpError::InvalidSecret)?; mac.update(message); let mut output = mac.finalize().into_bytes(); let digest = output.to_vec(); output.fill(0); Ok(digest) } fn find_uri( plaintext: &SecretBytes, entry: &EntryPath, ) -> Result, OtpUri)>, OtpError> { let mut found = None; let mut start = 0; while start < plaintext.expose().len() { let tail = &plaintext.expose()[start..]; let line_length = tail .iter() .position(|byte| *byte == b'\n') .unwrap_or(tail.len()); let end = start + line_length; let line = &plaintext.expose()[start..end]; if line.starts_with(b"otpauth://") { let uri = OtpUri::parse(SecretBytes::new(line.to_vec()))?; if found.is_some() { return Err(OtpError::AmbiguousUri { entry: entry.clone(), }); } found = Some((start..end, uri)); } if end == plaintext.expose().len() { break; } start = end + 1; } Ok(found) }