Implement OTP clipboard and QR TUI

This commit is contained in:
Hermes Agent
2026-08-10 11:11:53 +00:00
parent 1850846696
commit 71bbff934e
8 changed files with 1021 additions and 28 deletions

View File

@@ -46,6 +46,12 @@ impl ClipboardCancellations {
fn register(&mut self, cancellation: Sender<()>) {
self.0.push(cancellation);
}
fn cancel_all(&mut self) {
for cancellation in self.0.drain(..) {
let _ignored = cancellation.send(());
}
}
}
impl Drop for ClipboardCancellations {
@@ -75,6 +81,16 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
for result in executor.drain() {
app.apply_result(result);
}
if let Some(value) = app.take_clipboard_request() {
apply_app_effect(
&mut app,
AppEffect::CopyFocused(value),
&mut authentication,
&executor,
&mut git_control,
&mut clipboard_cancellations,
);
}
if !app.git_pending() {
git_control = None;
}
@@ -98,6 +114,34 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
terminal.draw(|frame| ui::draw(frame, &app))?;
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(
@@ -246,6 +290,15 @@ fn apply_app_effect(
);
}
}
AppEffect::AuthenticateOtp(request) => {
if let Some(coordinator) = authentication.as_mut() {
coordinator.request_otp(request);
} else {
app.authentication_failed(
"operating-system secure storage is unavailable".to_owned(),
);
}
}
AppEffect::CancelGit => {
if let Some(control) = git_control.as_ref() {
control.cancel();
@@ -264,6 +317,10 @@ fn apply_app_effect(
}
AppEffect::CopyFocused(value) => {
if let Some(config) = app.config().cloned() {
app.report_status(format!(
"Secret copied; cleanup in {}s",
config.clipboard_timeout().duration().as_secs()
));
let (cancel, cancellation) = mpsc::channel();
clipboard_cancellations.register(cancel);
let token = app.begin_request();
@@ -306,6 +363,7 @@ fn apply_app_effect(
executor.submit(token, move || Ok(save_document(&config, entry, editor)));
}
AppEffect::ManualLock => {
clipboard_cancellations.cancel_all();
if let Some(coordinator) = authentication.as_mut()
&& let Err(error) = coordinator.lock()
{
@@ -352,6 +410,16 @@ fn apply_app_effect(
executor.submit(token, move || execute_git(&config, request, None, &control));
}
}
AppEffect::RunCommand(ironstorage::command::CommandRequest::Otp(
ironstorage::command::OtpRequest::Validate { uri },
)) => {
let token = app.begin_request();
executor.submit(token, move || {
ironstorage::otp::OtpService::validate(&uri)
.map(|()| AsyncPayload::OtpValidated)
.map_err(|error| error.to_string())
});
}
AppEffect::RunCommand(request) => {
app.report_status(format!(
"{} is not implemented by this terminal workflow yet",
@@ -683,6 +751,78 @@ fn execute_git_resolution(
})
}
fn execute_otp_ui(
config: &ironstorage::config::Config,
request: crate::app::OtpUiRequest,
mut provider: ironstorage::authentication::NativeAuthenticationHandle,
) -> Result<AsyncPayload, String> {
use ironstorage::{
command::{OtpRequest, OtpUriPresentation},
otp::{OtpKind, OtpService},
presentation::QrMatrix,
};
let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?;
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
.map_err(|error| error.to_string())?;
let service = OtpService::new(&repository, &keys);
let field = request.field;
match request.request {
OtpRequest::Code(code_request) => {
let uri = service
.uri(&code_request.entry, &mut provider)
.map_err(|error| error.to_string())?;
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 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 code = ironstorage::repository::SecretBytes::new(outcome.code().expose().to_vec());
let tree = counter.map(|_| load_tree(config)).transpose()?;
Ok(AsyncPayload::OtpCodeFinished {
entry: code_request.entry,
field,
code,
remaining_seconds,
counter,
clipboard: code_request.clipboard,
tree,
})
}
OtpRequest::Uri(uri_request) => {
let uri = service
.uri(&uri_request.entry, &mut provider)
.map_err(|error| error.to_string())?;
let payload =
ironstorage::repository::SecretBytes::new(uri.encoded().expose().to_vec());
let (presentation, qr) = match uri_request.presentation {
OtpUriPresentation::Terminal => (crate::app::OtpPresentationTarget::Terminal, None),
OtpUriPresentation::Clipboard => {
(crate::app::OtpPresentationTarget::Clipboard, None)
}
OtpUriPresentation::QrCode => (
crate::app::OtpPresentationTarget::Qr,
Some(QrMatrix::encode(&payload).map_err(|error| error.to_string())?),
),
};
Ok(AsyncPayload::OtpUriFinished {
entry: uri_request.entry,
presentation,
payload,
qr,
})
}
_ => Err("this OTP request is not a presentation operation".to_owned()),
}
}
fn load_startup() -> Result<StartupData, String> {
let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
let repository = ironstorage::repository::Repository::open(config.vault())
@@ -769,6 +909,16 @@ fn apply_authentication_event(
execute_git(&config, request, Some(handle), &control)
});
}
AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => {
let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else {
app.authentication_failed(
"authentication completed without an active secure-store lease".to_owned(),
);
return;
};
let token = app.begin_request();
executor.submit(token, move || execute_otp_ui(&config, request, handle));
}
AuthenticationEvent::Failed { workflow, message } => {
if workflow {
app.workflow_authentication_failed(message);
@@ -805,6 +955,7 @@ fn execute_workflow(
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity,
},
mutation::TreeMutator,
otp::OtpService,
recipient::RecipientPolicyManager,
write::VaultWriter,
};
@@ -926,6 +1077,53 @@ fn execute_workflow(
.map(|selection| (selection.display_path(), selection.is_directory()));
(None, format!("Copied {source} to {destination}"))
}
WorkflowSubmission::OtpInsert { request, input } => {
let service = OtpService::new(&repository, &keys);
let plan = service
.prepare_insert(&request, input)
.map_err(|error| error.to_string())?;
let path = plan.path().to_string();
let mut committer =
AutomaticEntryCommitter::for_entry(&repository, &path, GitIdentity::ironstorage())
.map_err(|error| error.to_string())?;
let outcome = service
.finish_insert(
plan,
ironstorage::write::OverwriteDecision::Allow,
ironstorage::write::OverwriteDecision::Allow,
None,
&mut committer,
)
.map_err(|error| error.to_string())?;
selection = Some((outcome.path().to_string(), false));
(None, format!("Inserted OTP URI at {}", outcome.path()))
}
WorkflowSubmission::OtpAppend { request, input } => {
let service = OtpService::new(&repository, &keys);
let session = service
.begin_append(&request, provider)
.map_err(|error| error.to_string())?;
let path = session.path().to_string();
let mut committer =
AutomaticEntryCommitter::for_entry(&repository, &path, GitIdentity::ironstorage())
.map_err(|error| error.to_string())?;
let outcome = service
.finish_append(
session,
input,
ironstorage::write::OverwriteDecision::Allow,
None,
&mut committer,
)
.map_err(|error| error.to_string())?;
selection = Some((outcome.path().to_string(), false));
(None, format!("Updated OTP URI at {}", outcome.path()))
}
WorkflowSubmission::OtpValidate { uri } => {
OtpService::validate_input(uri).map_err(|error| error.to_string())?;
refresh_tree = false;
(None, "OTP URI is valid".to_owned())
}
};
let tree = refresh_tree
.then(|| {