Implement OTP clipboard and QR TUI
This commit is contained in:
@@ -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(|| {
|
||||
|
||||
Reference in New Issue
Block a user