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::{ mutation::{
MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator, MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator,
}, },
otp::{OtpError, OtpInput, OtpService}, otp::{OtpCodeOutcome, OtpCodeValidity, OtpError, OtpInput, OtpService},
presentation::{ presentation::{
ClipboardError, ClipboardTimeout, ClipboardWait, NativeClipboardManager, QrError, QrMatrix, 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), Err(error) => return operation_error(stderr, error),
}; };
if request.clipboard { if request.clipboard {
write_otp_validity(&outcome, timestamp, stderr)?;
match presentation.clipboard( match presentation.clipboard(
outcome.code(), outcome.code(),
config.clipboard_timeout(), config.clipboard_timeout(),
@@ -851,6 +852,7 @@ fn execute_otp<B: SecretStoreBackend, P: CliPresentation, I: OtpInteraction, O:
.write_all(outcome.code().expose()) .write_all(outcome.code().expose())
.and_then(|()| stdout.write_all(b"\n")) .and_then(|()| stdout.write_all(b"\n"))
.map_err(|_| ())?; .map_err(|_| ())?;
write_otp_validity(&outcome, timestamp, stderr)?;
Ok(EXIT_SUCCESS) 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 { trait OtpInteraction {
fn standard_input_is_terminal(&self) -> bool; fn standard_input_is_terminal(&self) -> bool;
@@ -2169,13 +2190,43 @@ mod tests {
assert_eq!(code.len(), 6); assert_eq!(code.len(), 6);
assert!(code.iter().all(u8::is_ascii_digit)); assert!(code.iter().all(u8::is_ascii_digit));
assert!(!stdout.windows(code.len()).any(|part| part == code)); 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(vault.join("otp/totp.gpg"))?, ciphertext_before);
assert_eq!(fs::read(&git_config)?, config_before); assert_eq!(fs::read(&git_config)?, config_before);
let git = GitRepository::open(&repository, identity)?; let git = GitRepository::open(&repository, identity)?;
assert_eq!(git.log(None)?.len(), commits_before); assert_eq!(git.log(None)?.len(), commits_before);
stdout.clear(); 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 = fs::read(fixtures.join("expected/basic/otp/totp.txt"))?;
let uri = uri let uri = uri
.split(|byte| *byte == b'\n') .split(|byte| *byte == b'\n')
@@ -2203,6 +2254,7 @@ mod tests {
assert!(stderr.is_empty()); assert!(stderr.is_empty());
stdout.clear(); stdout.clear();
stderr.clear();
assert_eq!( assert_eq!(
execute_secure_with_services( execute_secure_with_services(
&config, &config,
@@ -2321,6 +2373,7 @@ mod tests {
); );
stdout.clear(); stdout.clear();
stderr.clear();
assert_eq!( assert_eq!(
execute_secure_with_services( execute_secure_with_services(
&config, &config,
@@ -2340,6 +2393,10 @@ mod tests {
); );
assert_eq!(stdout.len(), 9); assert_eq!(stdout.len(), 9);
assert!(stdout[..8].iter().all(u8::is_ascii_digit)); 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 repository = Repository::open(&vault)?;
let keys = ironstorage::crypto::KeyStore::load(fixtures.join("keys"))?; let keys = ironstorage::crypto::KeyStore::load(fixtures.join("keys"))?;
@@ -2366,7 +2423,6 @@ mod tests {
&mut secrets, &mut secrets,
)?; )?;
assert!(hotp.expose().windows(9).any(|part| part == b"counter=1")); assert!(hotp.expose().windows(9).any(|part| part == b"counter=1"));
assert!(stderr.is_empty());
Ok(()) Ok(())
} }

View File

@@ -11,6 +11,7 @@ use ironstorage::{
crypto::KeyInfo, crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId}, document::{DocumentError, EntryDocument, EntryFieldId},
git::{GitConflict, GitProgressPhase, GitSnapshot}, git::{GitConflict, GitProgressPhase, GitSnapshot},
otp::OtpCodeValidity,
presentation::{ClipboardDisposition, QrMatrix}, presentation::{ClipboardDisposition, QrMatrix},
read::{FindResults, GrepResults, TreeModel}, read::{FindResults, GrepResults, TreeModel},
repository::SecretBytes, repository::SecretBytes,
@@ -95,8 +96,8 @@ pub struct OtpDisplay {
entry: String, entry: String,
field: Option<EntryFieldId>, field: Option<EntryFieldId>,
code: SecretBytes, code: SecretBytes,
validity: OtpCodeValidity,
remaining_seconds: Option<u64>, remaining_seconds: Option<u64>,
counter: Option<u64>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -135,7 +136,7 @@ impl OtpDisplay {
self.remaining_seconds self.remaining_seconds
} }
pub fn counter(&self) -> Option<u64> { pub fn counter(&self) -> Option<u64> {
self.counter self.validity.counter()
} }
} }
@@ -207,8 +208,8 @@ pub enum AsyncPayload {
entry: String, entry: String,
field: Option<EntryFieldId>, field: Option<EntryFieldId>,
code: SecretBytes, code: SecretBytes,
remaining_seconds: Option<u64>, validity: OtpCodeValidity,
counter: Option<u64>, observed_at: u64,
clipboard: bool, clipboard: bool,
tree: Option<TreeModel>, tree: Option<TreeModel>,
}, },
@@ -528,18 +529,16 @@ impl App {
pub fn tick(&mut self) { pub fn tick(&mut self) {
self.ticks = self.ticks.wrapping_add(1); self.ticks = self.ticks.wrapping_add(1);
if self.ticks.is_multiple_of(4) }
&& let Some(remaining) = self
.otp_display pub fn observe_time(&mut self, unix_seconds: u64) {
.as_mut() if let Some(display) = self.otp_display.as_mut() {
.and_then(|display| display.remaining_seconds.as_mut()) display.remaining_seconds = display.validity.remaining_at(unix_seconds);
{
*remaining = remaining.saturating_sub(1);
} }
} }
pub fn begin_totp_refresh(&mut self) -> Option<(String, EntryFieldId)> { 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; return None;
} }
let viewer = self.viewer.as_ref()?; let viewer = self.viewer.as_ref()?;
@@ -787,8 +786,8 @@ impl App {
entry, entry,
field, field,
code, code,
remaining_seconds, validity,
counter, observed_at,
clipboard, clipboard,
tree, tree,
}) => { }) => {
@@ -799,7 +798,7 @@ impl App {
if clipboard { if clipboard {
self.clipboard_request = Some(SecretBytes::new(code.expose().to_vec())); 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}") format!("Generated and committed HOTP counter {counter}")
} else { } else {
"TOTP code refreshed".to_owned() "TOTP code refreshed".to_owned()
@@ -808,8 +807,8 @@ impl App {
entry, entry,
field, field,
code, code,
remaining_seconds, validity,
counter, remaining_seconds: validity.remaining_at(observed_at),
}); });
self.hotp_confirmation = None; 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); 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()?; let size = terminal.size()?;
app.resize(size.width, size.height); app.resize(size.width, size.height);
terminal.draw(|frame| ui::draw_with_color_capability(frame, &app, color_capability))?; terminal.draw(|frame| ui::draw_with_color_capability(frame, &app, color_capability))?;
if !event::poll(TICK_INTERVAL)? { if !event::poll(TICK_INTERVAL)? {
app.tick(); 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(coordinator) = authentication.as_mut() {
if let Some(event) = coordinator.poll_lease() { if let Some(event) = coordinator.poll_lease() {
apply_authentication_event( apply_authentication_event(
@@ -265,6 +241,46 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
Ok(()) 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( fn handle_terminal_ownership_lost(
app: &mut App, app: &mut App,
authentication: &mut Option<AuthenticationCoordinator>, authentication: &mut Option<AuthenticationCoordinator>,
@@ -822,23 +838,20 @@ fn execute_otp_ui(
if uri.kind() == OtpKind::Hotp && !request.confirmed_hotp { if uri.kind() == OtpKind::Hotp && !request.confirmed_hotp {
return Err("HOTP generation requires explicit confirmation".to_owned()); return Err("HOTP generation requires explicit confirmation".to_owned());
} }
let unix_seconds = std::time::SystemTime::now() let unix_seconds = current_unix_seconds()
.duration_since(std::time::UNIX_EPOCH) .map_err(|_| "the system clock is before the Unix epoch".to_owned())?;
.map_err(|_| "the system clock is before the Unix epoch".to_owned())?
.as_secs();
let outcome = service let outcome = service
.code_automatic(&code_request.entry, unix_seconds, None, &mut provider) .code_automatic(&code_request.entry, unix_seconds, None, &mut provider)
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let remaining_seconds = outcome.remaining_at(unix_seconds); let validity = outcome.validity();
let counter = outcome.counter();
let code = ironstorage::repository::SecretBytes::new(outcome.code().expose().to_vec()); 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 { Ok(AsyncPayload::OtpCodeFinished {
entry: code_request.entry, entry: code_request.entry,
field, field,
code, code,
remaining_seconds, validity,
counter, observed_at: unix_seconds,
clipboard: code_request.clipboard, clipboard: code_request.clipboard,
tree, tree,
}) })

View File

@@ -1028,8 +1028,8 @@ mod tests {
entry: "otp/totp".to_owned(), entry: "otp/totp".to_owned(),
field: Some(wrong_field), field: Some(wrong_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()), code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
remaining_seconds: Some(12), validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 72 },
counter: None, observed_at: 60,
clipboard: false, clipboard: false,
tree: None, tree: None,
}), }),
@@ -1042,8 +1042,8 @@ mod tests {
entry: "otp/totp".to_owned(), entry: "otp/totp".to_owned(),
field: Some(otp_field), field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()), code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
remaining_seconds: Some(12), validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 72 },
counter: None, observed_at: 60,
clipboard: false, clipboard: false,
tree: None, tree: None,
}), }),
@@ -1053,6 +1053,30 @@ mod tests {
assert!(code.contains("12s remaining")); assert!(code.contains("12s remaining"));
assert!(!code.contains("JBSWY3DPEHPK3PXP")); 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( let payload = ironstorage::repository::SecretBytes::new(
b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(), b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(),
); );

View File

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

View File

@@ -9,7 +9,7 @@ use ironstorage::{
command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest}, command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest},
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
git::{GitIdentity, GitRepository}, git::{GitIdentity, GitRepository},
otp::{OtpAlgorithm, OtpError, OtpInput, OtpKind, OtpService, OtpUri}, otp::{OtpAlgorithm, OtpCodeValidity, OtpError, OtpInput, OtpKind, OtpService, OtpUri},
recipient::RecipientPolicyManager, recipient::RecipientPolicyManager,
repository::{EntryPath, Repository, SecretBytes}, repository::{EntryPath, Repository, SecretBytes},
write::{EntryCommit, EntryCommitError, EntryCommitter, OverwriteDecision}, 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)?; let totp = service.code_automatic("otp/totp", 59, None, &mut provider)?;
assert_eq!(totp.counter(), None); assert_eq!(totp.counter(), None);
assert_eq!(totp.valid_until(), Some(60)); 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(59), Some(1));
assert_eq!(totp.remaining_at(60), Some(0));
assert_eq!(repository.read_entry(&totp_path)?, totp_before); assert_eq!(repository.read_entry(&totp_path)?, totp_before);
let git = GitRepository::open(&repository, identity.clone())?; let git = GitRepository::open(&repository, identity.clone())?;
assert_eq!(git.log(None)?.len(), initial_commits); 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)?; let hotp = service.code_automatic("otp/hotp", 0, None, &mut provider)?;
assert_eq!(hotp.counter(), Some(1)); assert_eq!(hotp.counter(), Some(1));
assert_eq!(hotp.valid_until(), None); 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)); assert_eq!(service.uri("otp/hotp", &mut provider)?.counter(), Some(1));
let git = GitRepository::open(&repository, identity)?; let git = GitRepository::open(&repository, identity)?;
assert_eq!(git.log(None)?.len(), initial_commits + 1); 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 OTP codes support terminal or secret-safe clipboard presentation. URI output
supports terminal, clipboard, and the shared storage-owned QR matrix renderer. supports terminal, clipboard, and the shared storage-owned QR matrix renderer.
Clipboard and QR requests never print the underlying code or URI as plaintext. 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.