1235 lines
40 KiB
Rust
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)
|
|
}
|