From cec1cc350039a1d47711b15a9ad3e1c0be5e16c8 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 12:22:53 +0000 Subject: [PATCH] Improve TUI OTP readability --- apps/tui/COMMANDS.md | 13 +- apps/tui/src/app.rs | 43 ++-- apps/tui/src/ui.rs | 377 +++++++++++++++++++++++++++++++++--- crates/storage/src/otp.rs | 40 ++-- crates/storage/tests/otp.rs | 10 +- docs/otp.md | 15 +- 6 files changed, 433 insertions(+), 65 deletions(-) diff --git a/apps/tui/COMMANDS.md b/apps/tui/COMMANDS.md index 684d03b..82f929a 100644 --- a/apps/tui/COMMANDS.md +++ b/apps/tui/COMMANDS.md @@ -34,7 +34,9 @@ submits a command or creates extra entry lines. Losing terminal ownership (including terminal suspension/focus loss) immediately revokes the lease, cancels Git and clipboard work, removes plaintext presentation state, and returns to the locked screen. `NO_COLOR` or `TERM=dumb` selects the monochrome -fallback while preserving focus through text and bold attributes. +fallback while preserving focused selections through reverse-video attributes. +Selected rows override nested field colors so labels, masked values, metadata, +and OTP codes keep one high-contrast foreground across the complete selection. ## Pass and pass-otp coverage matrix @@ -65,7 +67,7 @@ where one is listed. | git fetch/sync | cancellable progress view | — | `:git fetch …`, `:git sync …` | | git pull/push | cancellable progress view | `g p` / `g P` | `:git pull …`, `:git push …` | | git conflict resolution | conflict view | — | `:git resolve-local`, `:git resolve-remote` | -| otp code | focused OTP field | `o c` | `:otp code ENTRY` | +| otp code | responsive entry OTP panel/compact field | `o c` | `:otp code ENTRY` | | otp code clipboard | clipboard feedback | `o y` | `:otp code --clip ENTRY` | | otp insert/append | masked OTP forms | `o i` / `o a` | `:otp insert …`, `:otp append …` | | otp URI terminal/clipboard/QR | secret popup/clipboard/QR | `o u` / `o x` / `o q` | `:otp uri [--clip\|--qrcode] ENTRY` | @@ -79,6 +81,13 @@ ticks, storage completion, Git progress, and clipboard timers do not extend the authentication lease. Clipboard and QR payloads are storage-produced, zeroizing values and disappear immediately on relock. +Opening an entry with a TOTP field requests its read-only code without requiring +field focus. If the viewer pane can retain both the code and field list, the TUI +renders five-row digit-only glyphs and a storage-period-driven countdown bar. +Shorter panes fall back to a leading inline code and remaining-seconds label. +The displayed TOTP survives field navigation, refreshes at its exact storage +boundary, and is removed when the entry closes or the application relocks. + `:quit` is a TUI convenience. Shell-completion script generation is a CLI build-time/integration surface; interactive Tab completion replaces it here. diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index 368ab50..e162fff 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -138,6 +138,9 @@ impl OtpDisplay { pub fn counter(&self) -> Option { self.validity.counter() } + pub fn period_seconds(&self) -> Option { + self.validity.period() + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -542,19 +545,33 @@ impl App { return None; } let viewer = self.viewer.as_ref()?; - let field = viewer.focused_field()?; - let otp = field.metadata().otp()?; - if otp.kind() != ironstorage::otp::OtpKind::Totp { - return None; - } let entry = self.selected_entry.clone()?; - if self.otp_display.as_ref().is_some_and(|display| { - display.entry == entry - && display.field == Some(field.id()) - && display.remaining_seconds != Some(0) - }) { - return None; - } + let field = if let Some(display) = self + .otp_display + .as_ref() + .filter(|display| display.entry == entry) + { + match display.remaining_seconds { + Some(0) => { + let field_id = display.field?; + viewer.document().fields().iter().find(|field| { + field.id() == field_id + && field + .metadata() + .otp() + .is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp) + })? + } + Some(_) | None => return None, + } + } else { + viewer.document().fields().iter().find(|field| { + field + .metadata() + .otp() + .is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp) + })? + }; self.otp_pending = true; Some((entry, field.id())) } @@ -998,13 +1015,11 @@ impl App { Action::FocusNext if self.mode == Mode::Viewer => { if let Some(viewer) = self.viewer.as_mut() { viewer.focus_next(); - self.otp_display = None; } } Action::FocusPrevious if self.mode == Mode::Viewer => { if let Some(viewer) = self.viewer.as_mut() { viewer.focus_previous(); - self.otp_display = None; } } Action::FocusNext if self.mode == Mode::Editor => { diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index 3efb07a..e9f519d 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -2,11 +2,12 @@ use ratatui::{ Frame, - layout::{Constraint, Direction, Layout, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, }; +use zeroize::Zeroizing; use crate::{ action::{context_actions, help_actions}, @@ -17,6 +18,8 @@ use crate::{ const MINIMUM_WIDTH: u16 = 40; const MINIMUM_HEIGHT: u16 = 8; +const LARGE_OTP_HEIGHT: u16 = 8; +const LARGE_OTP_ROWS: usize = 5; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ColorCapability { @@ -69,6 +72,9 @@ pub fn draw_with_color_capability(frame: &mut Frame, app: &App, capability: Colo draw_inner(frame, app); if capability == ColorCapability::Monochrome { for cell in &mut frame.buffer_mut().content { + if cell.fg == Color::White && cell.bg == Color::Blue { + cell.modifier.insert(Modifier::REVERSED); + } cell.set_fg(Color::Reset).set_bg(Color::Reset); } } @@ -232,6 +238,56 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { .wrap(Wrap { trim: false }), panes[0], ); + if app.mode() == Mode::Viewer + && let Some(viewer) = app.viewer() + && large_otp_fits(app, viewer, panes[1]) + { + let main_rows = + Layout::vertical([Constraint::Length(LARGE_OTP_HEIGHT), Constraint::Min(3)]) + .split(panes[1]); + render_large_otp( + frame, + app.otp_display().expect("large OTP was checked"), + main_rows[0], + ); + frame.render_widget( + Paragraph::new(viewer_lines(viewer, None)) + .scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0)) + .block(pane_block( + mode_title(app.mode()), + app.focus() == PaneFocus::Main, + )) + .wrap(Wrap { trim: false }), + main_rows[1], + ); + return; + } + if app.mode() == Mode::Viewer + && let Some(viewer) = app.viewer() + && let Some(display) = matching_totp_display(app, viewer) + { + let (otp_area, details_area) = if panes[1].height >= 6 { + let rows = + Layout::vertical([Constraint::Length(3), Constraint::Min(3)]).split(panes[1]); + (rows[0], Some(rows[1])) + } else { + (panes[1], None) + }; + render_compact_otp(frame, display, otp_area); + if let Some(details_area) = details_area { + frame.render_widget( + Paragraph::new(viewer_lines(viewer, None)) + .scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0)) + .block(pane_block( + mode_title(app.mode()), + app.focus() == PaneFocus::Main, + )) + .wrap(Wrap { trim: false }), + details_area, + ); + } + return; + } let main = match app.mode() { Mode::Viewer => app.viewer().map_or_else( || Paragraph::new(main_text(app)), @@ -267,6 +323,146 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { ); } +fn large_otp_fits(app: &App, viewer: &EntryViewer, area: Rect) -> bool { + let Some(display) = matching_totp_display(app, viewer) else { + return false; + }; + let code = display.code().expose(); + let glyph_width = code.len().saturating_mul(4).saturating_sub(1); + !code.is_empty() + && code.iter().all(u8::is_ascii_digit) + && usize::from(area.width.saturating_sub(2)) >= glyph_width + && area.height >= LARGE_OTP_HEIGHT.saturating_add(3) +} + +fn matching_totp_display<'a>( + app: &'a App, + viewer: &EntryViewer, +) -> Option<&'a crate::app::OtpDisplay> { + let display = app.otp_display()?; + if app.selected_entry() != Some(display.entry()) + || display.remaining_seconds().is_none() + || display.period_seconds().is_none() + { + return None; + } + let field_id = display.field()?; + viewer + .document() + .fields() + .iter() + .any(|field| { + field.id() == field_id + && field + .metadata() + .otp() + .is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp) + }) + .then_some(display) +} + +fn render_large_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: Rect) { + let glyphs = (0..LARGE_OTP_ROWS) + .map(|row| otp_ascii_row(display.code().expose(), row)) + .collect::>(); + let remaining = display.remaining_seconds().unwrap_or_default(); + let period = display.period_seconds().unwrap_or(1); + let inner_width = usize::from(area.width.saturating_sub(2)); + let mut lines = glyphs + .iter() + .map(|row| { + Line::styled( + row.as_str(), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ) + }) + .collect::>(); + lines.push(Line::styled( + countdown_bar(remaining, period, inner_width), + Style::default().fg(Color::Cyan), + )); + frame.render_widget( + Paragraph::new(lines) + .alignment(Alignment::Center) + .block(Block::bordered().title(format!("TOTP code — {remaining}s remaining"))), + area, + ); +} + +fn render_compact_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: Rect) { + let code = String::from_utf8_lossy(display.code().expose()); + let remaining = display.remaining_seconds().unwrap_or_default(); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + code.as_ref(), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" — {remaining}s remaining")), + ])) + .alignment(Alignment::Center) + .block(Block::bordered().title("TOTP code")), + area, + ); +} + +fn otp_ascii_row(code: &[u8], row: usize) -> Zeroizing { + let mut rendered = Zeroizing::new(String::with_capacity( + code.len().saturating_mul(4).saturating_sub(1), + )); + for (index, digit) in code.iter().copied().enumerate() { + if index != 0 { + rendered.push(' '); + } + rendered.push_str(otp_digit_glyph(digit, row)); + } + rendered +} + +fn otp_digit_glyph(digit: u8, row: usize) -> &'static str { + const GLYPHS: [[&str; LARGE_OTP_ROWS]; 10] = [ + ["000", "0 0", "0 0", "0 0", "000"], + [" 1 ", "11 ", " 1 ", " 1 ", "111"], + ["222", " 2", "222", "2 ", "222"], + ["333", " 3", "333", " 3", "333"], + ["4 4", "4 4", "444", " 4", " 4"], + ["555", "5 ", "555", " 5", "555"], + ["666", "6 ", "666", "6 6", "666"], + ["777", " 7", " 7 ", " 7 ", " 7 "], + ["888", "8 8", "888", "8 8", "888"], + ["999", "9 9", "999", " 9", "999"], + ]; + let index = usize::from(digit.saturating_sub(b'0')); + GLYPHS + .get(index) + .and_then(|glyph| glyph.get(row)) + .copied() + .unwrap_or(" ") +} + +fn countdown_bar(remaining: u64, period: u64, width: usize) -> String { + let label = format!("{remaining}s"); + let bar_width = width.saturating_sub(label.len().saturating_add(1)); + let period = period.max(1); + let remaining = remaining.min(period); + let filled = if remaining == 0 { + 0 + } else { + usize::try_from((u128::from(remaining) * bar_width as u128).div_ceil(u128::from(period))) + .unwrap_or(bar_width) + .min(bar_width) + }; + format!( + "{}{} {label}", + "█".repeat(filled), + "░".repeat(bar_width.saturating_sub(filled)) + ) +} + fn pane_block(title: &'static str, focused: bool) -> Block<'static> { let style = if focused { Style::default() @@ -281,6 +477,14 @@ fn pane_block(title: &'static str, focused: bool) -> Block<'static> { .border_style(style) } +fn selected_line<'a>(mut spans: Vec>) -> Line<'a> { + let style = Style::default().bg(Color::Blue).fg(Color::White); + for span in &mut spans { + span.style = span.style.bg(Color::Blue).fg(Color::White); + } + Line::from(spans).style(style) +} + fn sidebar_lines(app: &App) -> Vec> { let selected = app.sidebar().selected(); let rows = app.sidebar().visible_window(); @@ -408,8 +612,7 @@ fn viewer_lines<'a>( ), Style::default().fg(Color::DarkGray), )); - if focused == Some(index) - && let Some(display) = otp_display + if let Some(display) = otp_display && display .field() .is_none_or(|display_field| display_field == field.id()) @@ -423,19 +626,21 @@ fn viewer_lines<'a>( }, |remaining| format!(", {remaining}s remaining"), ); - spans.push(Span::styled( - format!(" code {code}{validity}"), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - )); + spans.insert( + 0, + Span::styled( + format!("code {code}{validity} "), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + ); } } - let line = Line::from(spans); if focused == Some(index) { - line.style(Style::default().bg(Color::Blue).fg(Color::White)) + selected_line(spans) } else { - line + Line::from(spans) } }) .collect() @@ -503,11 +708,10 @@ fn editor_lines(editor: &EntryEditor) -> Vec> { Style::default().fg(Color::Yellow), )); } - let line = Line::from(spans); if selected { - line.style(Style::default().bg(Color::Blue).fg(Color::White)) + selected_line(spans) } else { - line + Line::from(spans) } }) .collect() @@ -753,6 +957,14 @@ mod tests { .iter() .all(|cell| { cell.fg == Color::Reset && cell.bg == Color::Reset }) ); + assert!( + terminal + .backend() + .buffer() + .content + .iter() + .any(|cell| cell.modifier.contains(Modifier::REVERSED)) + ); terminal .draw(|frame| draw_with_color_capability(frame, &app, ColorCapability::Color)) @@ -1020,7 +1232,10 @@ mod tests { .expect("OTP field") .id(); app.open_test_document("otp/totp", document); - app.dispatch(crate::action::Action::FocusNext); + 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, @@ -1028,13 +1243,17 @@ mod tests { entry: "otp/totp".to_owned(), field: Some(wrong_field), code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()), - validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 72 }, + validity: ironstorage::otp::OtpCodeValidity::Timed { + valid_until: 72, + period: 30, + }, observed_at: 60, clipboard: false, tree: None, }), }); assert!(!render(120, 20, &app).contains("123456")); + app.dispatch(crate::action::Action::FocusNext); let token = app.begin_request(); app.apply_result(crate::app::AsyncResult { token, @@ -1042,19 +1261,38 @@ mod tests { entry: "otp/totp".to_owned(), field: Some(otp_field), code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()), - validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 72 }, + validity: ironstorage::otp::OtpCodeValidity::Timed { + valid_until: 72, + period: 30, + }, observed_at: 60, clipboard: false, tree: None, }), }); - let code = render(120, 20, &app); - assert!(code.contains("123456")); - assert!(code.contains("12s remaining")); - assert!(!code.contains("JBSWY3DPEHPK3PXP")); + app.dispatch(crate::action::Action::FocusPrevious); + let large = render(120, 20, &app); + assert!(large.contains("TOTP code — 12s remaining")); + assert!(large.contains("222 333")); + assert!(!large.contains("code 123456")); + assert!(!large.contains("JBSWY3DPEHPK3PXP")); + let full_bar = large.matches('█').count(); + + let compact = render(60, 16, &app); + assert!(compact.contains("123456 — 12s remaining")); + assert!(!compact.contains("TOTP code —")); + + app.observe_time(66); + let progressed = render(120, 20, &app); + assert!(progressed.contains("TOTP code — 6s remaining")); + let progressed_bar = progressed.matches('█').count(); + assert!(progressed_bar < full_bar); + assert!(progressed_bar > 0); app.observe_time(72); - assert!(render(120, 20, &app).contains("0s remaining")); + let expired = render(120, 20, &app); + assert!(expired.contains("0s remaining")); + assert_eq!(expired.matches('█').count(), 0); assert_eq!( app.begin_totp_refresh(), Some(("otp/totp".to_owned(), otp_field)) @@ -1066,16 +1304,23 @@ mod tests { 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 }, + validity: ironstorage::otp::OtpCodeValidity::Timed { + valid_until: 102, + period: 30, + }, 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")); + assert!(refreshed.contains("TOTP code — 30s remaining")); + assert!(refreshed.contains("666 555")); + assert!(refreshed.contains("333 222")); + assert!(!refreshed.contains("222 333")); + assert!(refreshed.matches('█').count() > full_bar); + let minimum = render(40, 8, &app); + assert!(minimum.contains("654321 — 30s remaining")); let payload = ironstorage::repository::SecretBytes::new( b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(), @@ -1100,9 +1345,87 @@ mod tests { )); let locked = render(120, 20, &app); assert!(!locked.contains("123456")); + assert!(!locked.contains("666 555")); assert!(!locked.contains("NEVER-RENDER")); } + #[test] + fn selected_viewer_spans_override_nested_colors_for_readable_contrast() { + let mut app = App::new(); + let document = fixture_document("otp/totp"); + let otp_field = document + .fields() + .iter() + .find(|field| field.metadata().otp().is_some()) + .expect("OTP field") + .id(); + app.open_test_document("otp/totp", document); + while app + .viewer() + .and_then(EntryViewer::focused_field) + .map(|field| field.id()) + != Some(otp_field) + { + app.dispatch(crate::action::Action::FocusNext); + } + 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"123456".to_vec()), + validity: ironstorage::otp::OtpCodeValidity::Timed { + valid_until: 90, + period: 30, + }, + observed_at: 60, + clipboard: false, + tree: None, + }), + }); + + let viewer = app.viewer().expect("viewer"); + let lines = viewer_lines(viewer, app.otp_display()); + let selected = &lines[viewer.focused_index().expect("focused field")]; + assert!( + selected + .spans + .iter() + .any(|span| span.content.contains("code 123456")) + ); + assert!(selected.spans.iter().all(|span| { + span.style.fg == Some(Color::White) && span.style.bg == Some(Color::Blue) + })); + + let editor = EntryEditor::new(fixture_document("email/personal")); + let editor_selected = &editor_lines(&editor)[0]; + assert!(editor_selected.spans.iter().all(|span| { + span.style.fg == Some(Color::White) && span.style.bg == Some(Color::Blue) + })); + + let backend = TestBackend::new(60, 16); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal + .draw(|frame| { + draw_with_color_capability(frame, &app, ColorCapability::Monochrome); + }) + .expect("monochrome viewer draw"); + let buffer = terminal.backend().buffer(); + assert!( + buffer + .content + .iter() + .any(|cell| cell.modifier.contains(Modifier::REVERSED)) + ); + assert!( + buffer + .content + .iter() + .all(|cell| cell.fg == Color::Reset && cell.bg == Color::Reset) + ); + } + #[test] fn closing_a_document_preserves_the_sidebar_selection() { let mut app = App::new(); diff --git a/crates/storage/src/otp.rs b/crates/storage/src/otp.rs index f4a7a15..f5bd390 100644 --- a/crates/storage/src/otp.rs +++ b/crates/storage/src/otp.rs @@ -433,17 +433,18 @@ pub struct OtpCodeOutcome { /// /// 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. +/// 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 }, + Timed { valid_until: u64, period: u64 }, CounterBased { counter: u64 }, } impl OtpCodeValidity { pub fn valid_until(self) -> Option { match self { - Self::Timed { valid_until } => Some(valid_until), + Self::Timed { valid_until, .. } => Some(valid_until), Self::CounterBased { .. } => None, } } @@ -455,6 +456,13 @@ impl OtpCodeValidity { } } + pub fn period(self) -> Option { + match self { + Self::Timed { period, .. } => Some(period), + Self::CounterBased { .. } => None, + } + } + pub fn remaining_at(self, unix_seconds: u64) -> Option { self.valid_until() .map(|valid_until| valid_until.saturating_sub(unix_seconds)) @@ -695,6 +703,7 @@ impl<'a> OtpService<'a> { .checked_add(1) .and_then(|counter| counter.checked_mul(period)) .ok_or(OtpError::CounterOverflow)?, + period, }, }); } @@ -743,18 +752,21 @@ impl<'a> OtpService<'a> { committer: &mut impl EntryCommitter, ) -> Result { match uri.kind() { - OtpKind::Totp => Ok(OtpCodeOutcome { - code: uri.code_at(unix_seconds)?, - 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::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)?; diff --git a/crates/storage/tests/otp.rs b/crates/storage/tests/otp.rs index 741e4d2..5ee8efd 100644 --- a/crates/storage/tests/otp.rs +++ b/crates/storage/tests/otp.rs @@ -478,7 +478,14 @@ 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.validity(), + OtpCodeValidity::Timed { + valid_until: 60, + period: 30, + } + ); + assert_eq!(totp.validity().period(), Some(30)); 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); @@ -494,6 +501,7 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes OtpCodeValidity::CounterBased { counter: 1 } ); assert_eq!(hotp.remaining_at(0), None); + assert_eq!(hotp.validity().period(), 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); diff --git a/docs/otp.md b/docs/otp.md index 1688ce3..ce0392b 100644 --- a/docs/otp.md +++ b/docs/otp.md @@ -50,13 +50,14 @@ 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. +`Timed { valid_until, period }` identifies the exclusive Unix-time boundary and +complete display interval for TOTP; frontends call its `remaining_at` method to +present a countdown and request a replacement at zero. The `CounterBased` +variant 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