Files
IronStorage/crates/storage/src/otp.rs
2026-08-10 12:22:53 +00:00

1235 lines
40 KiB
Rust

//! `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<String>,
account: String,
algorithm: OtpAlgorithm,
digits: u32,
period: Option<u64>,
counter: Option<u64>,
counter_value: Option<Range<usize>>,
}
impl OtpUri {
pub fn parse(encoded: SecretBytes) -> Result<Self, OtpError> {
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, OtpError> {
Self::parse(SecretBytes::new(encoded.as_bytes().to_vec()))
}
pub fn from_input(source: &OtpInputSource, input: OtpInput) -> Result<Self, OtpError> {
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<u64> {
self.period
}
pub fn counter(&self) -> Option<u64> {
self.counter
}
pub fn derived_entry(&self) -> Result<EntryPath, OtpError> {
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<SecretBytes, OtpError> {
let period = self.period.ok_or(OtpError::NotTotp)?;
self.code_for_counter(unix_seconds / period)
}
pub fn code_for_counter(&self, counter: u64) -> Result<SecretBytes, OtpError> {
let message = counter.to_be_bytes();
let mut digest = match self.algorithm {
OtpAlgorithm::Sha1 => hmac_digest::<Hmac<Sha1>>(self.secret.expose(), &message)?,
OtpAlgorithm::Sha256 => hmac_digest::<Hmac<Sha256>>(self.secret.expose(), &message)?,
OtpAlgorithm::Sha512 => hmac_digest::<Hmac<Sha512>>(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<u8>, mut confirmation: Vec<u8>) -> Result<Self, OtpError> {
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<u8>) -> Result<Self, OtpError> {
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<EncryptedEntry>,
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<Range<usize>>,
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<u64> {
match self {
Self::Timed { valid_until, .. } => Some(valid_until),
Self::CounterBased { .. } => None,
}
}
pub fn counter(self) -> Option<u64> {
match self {
Self::Timed { .. } => None,
Self::CounterBased { counter } => Some(counter),
}
}
pub fn period(self) -> Option<u64> {
match self {
Self::Timed { period, .. } => Some(period),
Self::CounterBased { .. } => None,
}
}
pub fn remaining_at(self, unix_seconds: u64) -> Option<u64> {
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<u64> {
self.validity.counter()
}
pub fn valid_until(&self) -> Option<u64> {
self.validity.valid_until()
}
pub fn validity(&self) -> OtpCodeValidity {
self.validity
}
pub fn remaining_at(&self, unix_seconds: u64) -> Option<u64> {
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<OtpInsertPlan, OtpError> {
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<OtpWriteOutcome, OtpError> {
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<OtpAppendSession, OtpError> {
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<OtpWriteOutcome, OtpError> {
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<OtpUri, OtpError> {
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<OtpCodeOutcome, OtpError> {
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<OtpCodeOutcome, OtpError> {
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<usize>, 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<usize>,
uri: OtpUri,
unix_seconds: u64,
signing: Option<&SigningPolicy>,
committer: &mut impl EntryCommitter,
) -> Result<OtpCodeOutcome, OtpError> {
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<RepositoryError> for OtpError {
fn from(error: RepositoryError) -> Self {
Self::Repository(error)
}
}
impl From<CryptoError> for OtpError {
fn from(error: CryptoError) -> Self {
Self::Crypto(error)
}
}
impl From<RecipientPolicyError> for OtpError {
fn from(error: RecipientPolicyError) -> Self {
Self::RecipientPolicy(error)
}
}
impl From<GitError> 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, OtpError> {
EntryPath::parse(input.trim_end_matches('/')).map_err(Into::into)
}
fn set_once<T>(
destination: &mut Option<T>,
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>, 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<String, OtpError> {
let decoded = decode_component(value)?;
if decoded.is_ascii() {
Ok(decoded)
} else {
Err(OtpError::InvalidParameter)
}
}
fn decode_component(value: &str) -> Result<String, OtpError> {
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<Vec<u8>, 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<u8> {
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<SecretBytes, OtpError> {
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<u64> {
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<OtpUri, OtpError> {
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<M>(key: &[u8], message: &[u8]) -> Result<Vec<u8>, OtpError>
where
M: hmac::digest::Mac + hmac::digest::KeyInit,
{
let mut mac =
<M as hmac::digest::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<Option<(Range<usize>, 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)
}