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(),
);