Show OTP code validity in frontends

This commit is contained in:
Hermes Agent
2026-08-10 11:59:30 +00:00
parent c2632656bc
commit d3427f3be6
7 changed files with 231 additions and 84 deletions

View File

@@ -426,8 +426,39 @@ impl OtpWriteOutcome {
pub struct OtpCodeOutcome {
code: SecretBytes,
counter: Option<u64>,
valid_until: Option<u64>,
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, while a counter-based code identifies the committed HOTP counter.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OtpCodeValidity {
Timed { valid_until: 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 remaining_at(self, unix_seconds: u64) -> Option<u64> {
self.valid_until()
.map(|valid_until| valid_until.saturating_sub(unix_seconds))
}
}
impl OtpCodeOutcome {
@@ -436,16 +467,19 @@ impl OtpCodeOutcome {
}
pub fn counter(&self) -> Option<u64> {
self.counter
self.validity.counter()
}
pub fn valid_until(&self) -> Option<u64> {
self.valid_until
self.validity.valid_until()
}
pub fn validity(&self) -> OtpCodeValidity {
self.validity
}
pub fn remaining_at(&self, unix_seconds: u64) -> Option<u64> {
self.valid_until
.map(|valid_until| valid_until.saturating_sub(unix_seconds))
self.validity.remaining_at(unix_seconds)
}
}
@@ -454,8 +488,7 @@ impl fmt::Debug for OtpCodeOutcome {
formatter
.debug_struct("OtpCodeOutcome")
.field("code", &"[REDACTED]")
.field("counter", &self.counter)
.field("valid_until", &self.valid_until)
.field("validity", &self.validity)
.finish()
}
}
@@ -657,13 +690,12 @@ impl<'a> OtpService<'a> {
let period = uri.period().ok_or(OtpError::NotTotp)?;
return Ok(OtpCodeOutcome {
code: uri.code_at(unix_seconds)?,
counter: None,
valid_until: Some(
(unix_seconds / period)
validity: OtpCodeValidity::Timed {
valid_until: (unix_seconds / period)
.checked_add(1)
.and_then(|counter| counter.checked_mul(period))
.ok_or(OtpError::CounterOverflow)?,
),
},
});
}
let entry = path.to_string();
@@ -713,14 +745,15 @@ impl<'a> OtpService<'a> {
match uri.kind() {
OtpKind::Totp => Ok(OtpCodeOutcome {
code: uri.code_at(unix_seconds)?,
counter: None,
valid_until: Some({
let period = uri.period().ok_or(OtpError::NotTotp)?;
(unix_seconds / period)
.checked_add(1)
.and_then(|counter| counter.checked_mul(period))
.ok_or(OtpError::CounterOverflow)?
}),
validity: OtpCodeValidity::Timed {
valid_until: {
let period = uri.period().ok_or(OtpError::NotTotp)?;
(unix_seconds / period)
.checked_add(1)
.and_then(|counter| counter.checked_mul(period))
.ok_or(OtpError::CounterOverflow)?
},
},
}),
OtpKind::Hotp => {
let (counter, incremented) = uri.incremented_hotp()?;
@@ -742,8 +775,7 @@ impl<'a> OtpService<'a> {
)?;
Ok(OtpCodeOutcome {
code,
counter: Some(counter),
valid_until: None,
validity: OtpCodeValidity::CounterBased { counter },
})
}
}