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

@@ -29,7 +29,7 @@ use ironstorage::{
mutation::{
MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator,
},
otp::{OtpError, OtpInput, OtpService},
otp::{OtpCodeOutcome, OtpCodeValidity, OtpError, OtpInput, OtpService},
presentation::{
ClipboardError, ClipboardTimeout, ClipboardWait, NativeClipboardManager, QrError, QrMatrix,
},
@@ -837,6 +837,7 @@ fn execute_otp<B: SecretStoreBackend, P: CliPresentation, I: OtpInteraction, O:
Err(error) => return operation_error(stderr, error),
};
if request.clipboard {
write_otp_validity(&outcome, timestamp, stderr)?;
match presentation.clipboard(
outcome.code(),
config.clipboard_timeout(),
@@ -851,6 +852,7 @@ fn execute_otp<B: SecretStoreBackend, P: CliPresentation, I: OtpInteraction, O:
.write_all(outcome.code().expose())
.and_then(|()| stdout.write_all(b"\n"))
.map_err(|_| ())?;
write_otp_validity(&outcome, timestamp, stderr)?;
Ok(EXIT_SUCCESS)
}
}
@@ -969,6 +971,25 @@ fn execute_otp<B: SecretStoreBackend, P: CliPresentation, I: OtpInteraction, O:
}
}
fn write_otp_validity(
outcome: &OtpCodeOutcome,
unix_seconds: u64,
feedback: &mut dyn Write,
) -> Result<(), ()> {
match outcome.validity() {
OtpCodeValidity::Timed { .. } => {
let remaining = outcome.remaining_at(unix_seconds).unwrap_or_default();
let unit = if remaining == 1 { "second" } else { "seconds" };
writeln!(feedback, "TOTP code is valid for {remaining} {unit}.").map_err(|_| ())
}
OtpCodeValidity::CounterBased { counter } => writeln!(
feedback,
"HOTP counter {counter} is counter-based and has no time expiry."
)
.map_err(|_| ()),
}
}
trait OtpInteraction {
fn standard_input_is_terminal(&self) -> bool;
@@ -2169,13 +2190,43 @@ mod tests {
assert_eq!(code.len(), 6);
assert!(code.iter().all(u8::is_ascii_digit));
assert!(!stdout.windows(code.len()).any(|part| part == code));
assert!(stderr.is_empty());
assert_eq!(
String::from_utf8_lossy(&stderr),
"TOTP code is valid for 1 second.\n"
);
assert_eq!(fs::read(vault.join("otp/totp.gpg"))?, ciphertext_before);
assert_eq!(fs::read(&git_config)?, config_before);
let git = GitRepository::open(&repository, identity)?;
assert_eq!(git.log(None)?.len(), commits_before);
stdout.clear();
stderr.clear();
assert_eq!(
execute_secure_with_services(
&config,
&CommandRequest::Otp(OtpRequest::Code(OtpCodeRequest {
entry: "otp/totp".to_owned(),
clipboard: false,
})),
&mut secrets,
&mut presentation,
&mut interaction,
|| Ok(59),
&mut stdout,
&mut stderr,
)
.expect("memory output cannot fail"),
EXIT_SUCCESS
);
assert_eq!(stdout.len(), 7);
assert!(stdout[..6].iter().all(u8::is_ascii_digit));
assert_eq!(
String::from_utf8_lossy(&stderr),
"TOTP code is valid for 1 second.\n"
);
stdout.clear();
stderr.clear();
let uri = fs::read(fixtures.join("expected/basic/otp/totp.txt"))?;
let uri = uri
.split(|byte| *byte == b'\n')
@@ -2203,6 +2254,7 @@ mod tests {
assert!(stderr.is_empty());
stdout.clear();
stderr.clear();
assert_eq!(
execute_secure_with_services(
&config,
@@ -2321,6 +2373,7 @@ mod tests {
);
stdout.clear();
stderr.clear();
assert_eq!(
execute_secure_with_services(
&config,
@@ -2340,6 +2393,10 @@ mod tests {
);
assert_eq!(stdout.len(), 9);
assert!(stdout[..8].iter().all(u8::is_ascii_digit));
assert_eq!(
String::from_utf8_lossy(&stderr),
"HOTP counter 1 is counter-based and has no time expiry.\n"
);
let repository = Repository::open(&vault)?;
let keys = ironstorage::crypto::KeyStore::load(fixtures.join("keys"))?;
@@ -2366,7 +2423,6 @@ mod tests {
&mut secrets,
)?;
assert!(hotp.expose().windows(9).any(|part| part == b"counter=1"));
assert!(stderr.is_empty());
Ok(())
}

View File

@@ -11,6 +11,7 @@ use ironstorage::{
crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId},
git::{GitConflict, GitProgressPhase, GitSnapshot},
otp::OtpCodeValidity,
presentation::{ClipboardDisposition, QrMatrix},
read::{FindResults, GrepResults, TreeModel},
repository::SecretBytes,
@@ -95,8 +96,8 @@ pub struct OtpDisplay {
entry: String,
field: Option<EntryFieldId>,
code: SecretBytes,
validity: OtpCodeValidity,
remaining_seconds: Option<u64>,
counter: Option<u64>,
}
#[derive(Debug)]
@@ -135,7 +136,7 @@ impl OtpDisplay {
self.remaining_seconds
}
pub fn counter(&self) -> Option<u64> {
self.counter
self.validity.counter()
}
}
@@ -207,8 +208,8 @@ pub enum AsyncPayload {
entry: String,
field: Option<EntryFieldId>,
code: SecretBytes,
remaining_seconds: Option<u64>,
counter: Option<u64>,
validity: OtpCodeValidity,
observed_at: u64,
clipboard: bool,
tree: Option<TreeModel>,
},
@@ -528,18 +529,16 @@ impl App {
pub fn tick(&mut self) {
self.ticks = self.ticks.wrapping_add(1);
if self.ticks.is_multiple_of(4)
&& let Some(remaining) = self
.otp_display
.as_mut()
.and_then(|display| display.remaining_seconds.as_mut())
{
*remaining = remaining.saturating_sub(1);
}
pub fn observe_time(&mut self, unix_seconds: u64) {
if let Some(display) = self.otp_display.as_mut() {
display.remaining_seconds = display.validity.remaining_at(unix_seconds);
}
}
pub fn begin_totp_refresh(&mut self) -> Option<(String, EntryFieldId)> {
if self.otp_pending || !self.ticks.is_multiple_of(4) || self.mode != Mode::Viewer {
if self.otp_pending || self.mode != Mode::Viewer {
return None;
}
let viewer = self.viewer.as_ref()?;
@@ -787,8 +786,8 @@ impl App {
entry,
field,
code,
remaining_seconds,
counter,
validity,
observed_at,
clipboard,
tree,
}) => {
@@ -799,7 +798,7 @@ impl App {
if clipboard {
self.clipboard_request = Some(SecretBytes::new(code.expose().to_vec()));
}
self.status = if let Some(counter) = counter {
self.status = if let Some(counter) = validity.counter() {
format!("Generated and committed HOTP counter {counter}")
} else {
"TOTP code refreshed".to_owned()
@@ -808,8 +807,8 @@ impl App {
entry,
field,
code,
remaining_seconds,
counter,
validity,
remaining_seconds: validity.remaining_at(observed_at),
});
self.hotp_confirmation = None;
}

View File

@@ -111,39 +111,15 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
apply_authentication_event(&mut app, coordinator, &executor, &mut git_control, event);
}
let unix_seconds = current_unix_seconds().map_err(io::Error::other)?;
app.observe_time(unix_seconds);
schedule_totp_refresh(&mut app, authentication.as_ref(), &executor);
let size = terminal.size()?;
app.resize(size.width, size.height);
terminal.draw(|frame| ui::draw_with_color_capability(frame, &app, color_capability))?;
if !event::poll(TICK_INTERVAL)? {
app.tick();
if let Some((entry, field)) = app.begin_totp_refresh() {
if let (Some(handle), Some(config)) = (
authentication
.as_ref()
.and_then(AuthenticationCoordinator::handle),
app.config().cloned(),
) {
let token = app.begin_request();
executor.submit(token, move || {
execute_otp_ui(
&config,
crate::app::OtpUiRequest {
request: ironstorage::command::OtpRequest::Code(
ironstorage::command::OtpCodeRequest {
entry,
clipboard: false,
},
),
confirmed_hotp: false,
field: Some(field),
},
handle,
)
});
} else {
app.authentication_failed("authentication lease expired".to_owned());
}
}
if let Some(coordinator) = authentication.as_mut() {
if let Some(event) = coordinator.poll_lease() {
apply_authentication_event(
@@ -265,6 +241,46 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
Ok(())
}
fn current_unix_seconds() -> Result<u64, std::time::SystemTimeError> {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
}
fn schedule_totp_refresh(
app: &mut App,
authentication: Option<&AuthenticationCoordinator>,
executor: &AsyncExecutor,
) {
let Some((entry, field)) = app.begin_totp_refresh() else {
return;
};
let (Some(handle), Some(config)) = (
authentication.and_then(AuthenticationCoordinator::handle),
app.config().cloned(),
) else {
app.authentication_failed("authentication lease expired".to_owned());
return;
};
let token = app.begin_request();
executor.submit(token, move || {
execute_otp_ui(
&config,
crate::app::OtpUiRequest {
request: ironstorage::command::OtpRequest::Code(
ironstorage::command::OtpCodeRequest {
entry,
clipboard: false,
},
),
confirmed_hotp: false,
field: Some(field),
},
handle,
)
});
}
fn handle_terminal_ownership_lost(
app: &mut App,
authentication: &mut Option<AuthenticationCoordinator>,
@@ -822,23 +838,20 @@ fn execute_otp_ui(
if uri.kind() == OtpKind::Hotp && !request.confirmed_hotp {
return Err("HOTP generation requires explicit confirmation".to_owned());
}
let unix_seconds = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|_| "the system clock is before the Unix epoch".to_owned())?
.as_secs();
let unix_seconds = current_unix_seconds()
.map_err(|_| "the system clock is before the Unix epoch".to_owned())?;
let outcome = service
.code_automatic(&code_request.entry, unix_seconds, None, &mut provider)
.map_err(|error| error.to_string())?;
let remaining_seconds = outcome.remaining_at(unix_seconds);
let counter = outcome.counter();
let validity = outcome.validity();
let code = ironstorage::repository::SecretBytes::new(outcome.code().expose().to_vec());
let tree = counter.map(|_| load_tree(config)).transpose()?;
let tree = validity.counter().map(|_| load_tree(config)).transpose()?;
Ok(AsyncPayload::OtpCodeFinished {
entry: code_request.entry,
field,
code,
remaining_seconds,
counter,
validity,
observed_at: unix_seconds,
clipboard: code_request.clipboard,
tree,
})

View File

@@ -1028,8 +1028,8 @@ mod tests {
entry: "otp/totp".to_owned(),
field: Some(wrong_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
remaining_seconds: Some(12),
counter: None,
validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 72 },
observed_at: 60,
clipboard: false,
tree: None,
}),
@@ -1042,8 +1042,8 @@ mod tests {
entry: "otp/totp".to_owned(),
field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
remaining_seconds: Some(12),
counter: None,
validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 72 },
observed_at: 60,
clipboard: false,
tree: None,
}),
@@ -1053,6 +1053,30 @@ mod tests {
assert!(code.contains("12s remaining"));
assert!(!code.contains("JBSWY3DPEHPK3PXP"));
app.observe_time(72);
assert!(render(120, 20, &app).contains("0s remaining"));
assert_eq!(
app.begin_totp_refresh(),
Some(("otp/totp".to_owned(), otp_field))
);
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
entry: "otp/totp".to_owned(),
field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"654321".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 102 },
observed_at: 72,
clipboard: false,
tree: None,
}),
});
let refreshed = render(120, 20, &app);
assert!(refreshed.contains("654321"));
assert!(refreshed.contains("30s remaining"));
assert!(!refreshed.contains("123456"));
let payload = ironstorage::repository::SecretBytes::new(
b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(),
);

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({
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 },
})
}
}

View File

@@ -9,7 +9,7 @@ use ironstorage::{
command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest},
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
git::{GitIdentity, GitRepository},
otp::{OtpAlgorithm, OtpError, OtpInput, OtpKind, OtpService, OtpUri},
otp::{OtpAlgorithm, OtpCodeValidity, OtpError, OtpInput, OtpKind, OtpService, OtpUri},
recipient::RecipientPolicyManager,
repository::{EntryPath, Repository, SecretBytes},
write::{EntryCommit, EntryCommitError, EntryCommitter, OverwriteDecision},
@@ -478,7 +478,9 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes
let totp = service.code_automatic("otp/totp", 59, None, &mut provider)?;
assert_eq!(totp.counter(), None);
assert_eq!(totp.valid_until(), Some(60));
assert_eq!(totp.validity(), OtpCodeValidity::Timed { valid_until: 60 });
assert_eq!(totp.remaining_at(59), Some(1));
assert_eq!(totp.remaining_at(60), Some(0));
assert_eq!(repository.read_entry(&totp_path)?, totp_before);
let git = GitRepository::open(&repository, identity.clone())?;
assert_eq!(git.log(None)?.len(), initial_commits);
@@ -487,6 +489,11 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes
let hotp = service.code_automatic("otp/hotp", 0, None, &mut provider)?;
assert_eq!(hotp.counter(), Some(1));
assert_eq!(hotp.valid_until(), None);
assert_eq!(
hotp.validity(),
OtpCodeValidity::CounterBased { counter: 1 }
);
assert_eq!(hotp.remaining_at(0), None);
assert_eq!(service.uri("otp/hotp", &mut provider)?.counter(), Some(1));
let git = GitRepository::open(&repository, identity)?;
assert_eq!(git.log(None)?.len(), initial_commits + 1);

View File

@@ -48,3 +48,19 @@ code whose counter update was not committed.
OTP codes support terminal or secret-safe clipboard presentation. URI output
supports terminal, clipboard, and the shared storage-owned QR matrix renderer.
Clipboard and QR requests never print the underlying code or URI as plaintext.
Every generated code carries an `OtpCodeValidity` value from `crates/storage`.
`Timed { valid_until }` identifies the exclusive Unix-time boundary for TOTP;
frontends call its `remaining_at` method to present a countdown and request a
replacement at zero. `CounterBased { counter }` identifies the HOTP counter
whose increment was committed and must be described as counter-based rather
than time-limited. Frontends must not recover periods from OTP URIs, decrement
an assumed interval, or infer the kind from formatted text. This same contract
is intended for the terminal, desktop, Apple, AutoFill, and watch interfaces.
The CLI preserves code-only standard output for pass-compatible pipelines and
reports the non-secret validity description on standard error. Clipboard
lifecycle feedback remains separate, and clipboard-only presentation does not
echo the code. The TUI observes the system clock during its normal repaint loop,
asks the storage validity value for the remaining seconds, and refreshes at the
exact boundary without treating repainting as user activity.