Compare commits
34 Commits
41f8e8f2ab
...
v0.3.3
Author | SHA1 | Date | |
---|---|---|---|
3d093a3a45 | |||
992d2a4d06 | |||
12f0f187a6 | |||
997e8b419f | |||
1d9132de3b | |||
e1c2618dc8 | |||
a7df7adc8e | |||
03d164c9d3 | |||
f522674a1c | |||
51fcccafa2 | |||
e3913ab4c9 | |||
c16f21bba3 | |||
61d9acc7c6 | |||
8d7b01629d | |||
5685948608 | |||
c98a065587 | |||
e46c3d2b4d | |||
fa228acc3a | |||
e7e0f9d33e | |||
a51b20add7 | |||
890f715388 | |||
89bc74e644 | |||
60c24e3ee4 | |||
486001b584 | |||
52c949e396 | |||
d7c5c2f37b | |||
ae5b8f31db | |||
c260e37e78 | |||
7501253970 | |||
5b9c711008 | |||
ddd1005067 | |||
e866a4a643 | |||
94400ba7d5 | |||
616600687d |
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@ src-tauri/target/
|
|||||||
|
|
||||||
# just in case
|
# just in case
|
||||||
credentials*
|
credentials*
|
||||||
|
!credentials.rs
|
||||||
|
9
doc/cryptography.md
Normal file
9
doc/cryptography.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
My original plan was to use [libsodium](https://doc.libsodium.org/) to handle encryption. However, the Rust bindings for libsodium are no longer actively maintained, which left me uncomfortable with using it. Instead, I switched to the [RustCrypto](https://github.com/RustCrypto) implementations of the same (or nearly the same) cryptographic primitives provided by libsodium.
|
||||||
|
|
||||||
|
Creddy makes use of two cryptographic primitives: A key-derivation function, which is currently `argon2id`, and a symmetric encryption algorithm, currently `XChaCha20Poly1305`.
|
||||||
|
* I chose `argon2id` because it's what libsodium uses, and because its difficulty parameters admit of very granular tuning.
|
||||||
|
* I chose `XChaCha20Poly1305` because it's _almost_ what libsodium uses - libsodium uses `XSalsa20Poly1305`, and it's my undersatnding that `XChaCha20Poly1305` is an evolution of the former. In both cases I use the eXtended variants, which make use of longer (24-byte) nonces than the non-X variants. This appealed to me because I wanted to be able to randomly generate a nonce every time I needed one, and I have seen [recommendations](https://latacora.micro.blog/2018/04/03/cryptographic-right-answers.html) that the 12-byte nonces used by the non-X variants are _juuust_ a touch small for that to be truly worry-free. The RustCrypto implementation of `XChaCha20Poly1305` has also been subject to a security audit, which is nice.
|
||||||
|
|
||||||
|
I tuned the `argon2id` parameters so that key-derivation would take ~800ms on my Ryzen 1600X. This is probably overkill, but I don't intend for key-derivation to be a frequent occurrence - no more than once a day, under normal circumstances. Taking in the neighborhood of 1 second seemed about the longest I could reasonably go.
|
||||||
|
|
||||||
|
**DISCLAIMER**: I am not a professional cryptographer, merely an interested amateur. While I've tried to be as careful as possible with selecting and making use of the cryptographic building blocks I've chosen here, there is always the possibility that I've screwed something up. If anyone would like to sponsor an _actual_ security review of Creddy by people who _actually_ know what they're doing instead of just what they've read on the internet, please let me know.
|
18
doc/todo.md
Normal file
18
doc/todo.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
## Definitely
|
||||||
|
|
||||||
|
* Switch to "process" provider for AWS credentials (much less hacky)
|
||||||
|
* Session timeout (plain duration, or activity-based?)
|
||||||
|
* ~Fix rehide behavior when new request comes in while old one is still being resolved~
|
||||||
|
* Additional hotkey configuration (approve/deny at the very least)
|
||||||
|
* Logging
|
||||||
|
* Icon
|
||||||
|
* Auto-updates
|
||||||
|
* SSH key handling
|
||||||
|
|
||||||
|
## Maybe
|
||||||
|
|
||||||
|
* Flatten error type hierarchy
|
||||||
|
* Rehide after terminal launch from locked
|
||||||
|
* Generalize Request across both credentials and terminal launch?
|
||||||
|
* Make hotkey configuration a little more tolerant of slight mistiming
|
||||||
|
* Distinguish between request that was denied and request that was canceled (e.g. due to error)
|
664
package-lock.json
generated
664
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "creddy",
|
"name": "creddy",
|
||||||
"version": "0.1.0",
|
"version": "0.3.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
@ -1 +1 @@
|
|||||||
DATABASE_URL=sqlite://creddy.db?mode=rwc
|
DATABASE_URL=sqlite://C:/Users/Joe/AppData/Roaming/creddy/creddy.dev.db
|
||||||
|
1569
src-tauri/Cargo.lock
generated
1569
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,14 +1,22 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "creddy"
|
||||||
version = "0.1.0"
|
version = "0.3.3"
|
||||||
description = "A Tauri App"
|
description = "A friendly AWS credentials manager"
|
||||||
authors = ["you"]
|
authors = ["Joseph Montanaro"]
|
||||||
license = ""
|
license = ""
|
||||||
repository = ""
|
repository = ""
|
||||||
default-run = "app"
|
default-run = "creddy"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.57"
|
rust-version = "1.57"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "creddy_cli"
|
||||||
|
path = "src/bin/creddy_cli.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "creddy"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
@ -17,7 +25,7 @@ tauri-build = { version = "1.0.4", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
tauri = { version = "1.2", features = ["dialog", "os-all", "system-tray"] }
|
tauri = { version = "1.2", features = ["dialog", "dialog-open", "global-shortcut", "os-all", "system-tray"] }
|
||||||
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
|
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
|
||||||
sodiumoxide = "0.2.7"
|
sodiumoxide = "0.2.7"
|
||||||
tokio = { version = ">=1.19", features = ["full"] }
|
tokio = { version = ">=1.19", features = ["full"] }
|
||||||
@ -34,6 +42,11 @@ strum = "0.24"
|
|||||||
strum_macros = "0.24"
|
strum_macros = "0.24"
|
||||||
auto-launch = "0.4.0"
|
auto-launch = "0.4.0"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
|
clap = { version = "3.2.23", features = ["derive"] }
|
||||||
|
is-terminal = "0.4.7"
|
||||||
|
argon2 = { version = "0.5.0", features = ["std"] }
|
||||||
|
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
|
||||||
|
which = "4.4.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# by default Tauri runs in production mode
|
# by default Tauri runs in production mode
|
||||||
|
22
src-tauri/conf/cli.wxs
Normal file
22
src-tauri/conf/cli.wxs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
|
||||||
|
<Fragment>
|
||||||
|
|
||||||
|
<DirectoryRef Id="INSTALLDIR">
|
||||||
|
<!-- Create a subdirectory for the console binary so that we can add it to PATH -->
|
||||||
|
<Directory Id="BinDir" Name="bin">
|
||||||
|
<Component Id="CliBinary" Guid="b6358c8e-504f-41fd-b14b-38af821dcd04">
|
||||||
|
<!-- Same name as the main executable, so that it can be invoked as just "creddy" -->
|
||||||
|
<File Id="Bin_Cli" Source="..\..\creddy_cli.exe" Name="creddy.exe" KeyPath="yes"/>
|
||||||
|
</Component>
|
||||||
|
</Directory>
|
||||||
|
</DirectoryRef>
|
||||||
|
|
||||||
|
<DirectoryRef Id="TARGETDIR">
|
||||||
|
<Component Id="AddToPath" Guid="b5fdaf7e-94f2-4aad-9144-aa3a8edfa675">
|
||||||
|
<Environment Id="CreddyInstallDir" Action="set" Name="PATH" Part="last" Permanent="no" Value="[BinDir]" />
|
||||||
|
</Component>
|
||||||
|
</DirectoryRef>
|
||||||
|
|
||||||
|
</Fragment>
|
||||||
|
</Wix>
|
116
src-tauri/src/app.rs
Normal file
116
src-tauri/src/app.rs
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use sqlx::{
|
||||||
|
SqlitePool,
|
||||||
|
sqlite::SqlitePoolOptions,
|
||||||
|
sqlite::SqliteConnectOptions,
|
||||||
|
};
|
||||||
|
use tauri::{
|
||||||
|
App,
|
||||||
|
AppHandle,
|
||||||
|
Manager,
|
||||||
|
async_runtime as rt,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::{self, AppConfig},
|
||||||
|
credentials::Session,
|
||||||
|
ipc,
|
||||||
|
server::Server,
|
||||||
|
errors::*,
|
||||||
|
state::AppState,
|
||||||
|
tray,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
pub static APP: OnceCell<AppHandle> = OnceCell::new();
|
||||||
|
|
||||||
|
|
||||||
|
pub fn run() -> tauri::Result<()> {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||||
|
app.get_window("main")
|
||||||
|
.map(|w| w.show().error_popup("Failed to show main window"));
|
||||||
|
}))
|
||||||
|
.system_tray(tray::create())
|
||||||
|
.on_system_tray_event(tray::handle_event)
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
ipc::unlock,
|
||||||
|
ipc::respond,
|
||||||
|
ipc::get_session_status,
|
||||||
|
ipc::save_credentials,
|
||||||
|
ipc::get_config,
|
||||||
|
ipc::save_config,
|
||||||
|
ipc::launch_terminal,
|
||||||
|
ipc::get_setup_errors,
|
||||||
|
])
|
||||||
|
.setup(|app| rt::block_on(setup(app)))
|
||||||
|
.build(tauri::generate_context!())?
|
||||||
|
.run(|app, run_event| match run_event {
|
||||||
|
tauri::RunEvent::WindowEvent { label, event, .. } => match event {
|
||||||
|
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||||
|
let _ = app.get_window(&label).map(|w| w.hide());
|
||||||
|
api.prevent_close();
|
||||||
|
}
|
||||||
|
_ => ()
|
||||||
|
}
|
||||||
|
_ => ()
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn connect_db() -> Result<SqlitePool, SetupError> {
|
||||||
|
let conn_opts = SqliteConnectOptions::new()
|
||||||
|
.filename(config::get_or_create_db_path()?)
|
||||||
|
.create_if_missing(true);
|
||||||
|
let pool_opts = SqlitePoolOptions::new();
|
||||||
|
let pool: SqlitePool = pool_opts.connect_with(conn_opts).await?;
|
||||||
|
sqlx::migrate!().run(&pool).await?;
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
|
||||||
|
APP.set(app.handle()).unwrap();
|
||||||
|
|
||||||
|
// get_or_create_db_path doesn't create the actual db file, just the directory
|
||||||
|
let is_first_launch = !config::get_or_create_db_path()?.exists();
|
||||||
|
let pool = connect_db().await?;
|
||||||
|
let mut setup_errors: Vec<String> = vec![];
|
||||||
|
|
||||||
|
let conf = match AppConfig::load(&pool).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(SetupError::ConfigParseError(_)) => {
|
||||||
|
setup_errors.push(
|
||||||
|
"Could not load configuration from database. Reverting to defaults.".into()
|
||||||
|
);
|
||||||
|
AppConfig::default()
|
||||||
|
},
|
||||||
|
err => err?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = Session::load(&pool).await?;
|
||||||
|
let srv = Server::new(conf.listen_addr, conf.listen_port, app.handle()).await?;
|
||||||
|
|
||||||
|
config::set_auto_launch(conf.start_on_login)?;
|
||||||
|
if let Err(_e) = config::set_auto_launch(conf.start_on_login) {
|
||||||
|
setup_errors.push("Error: Failed to manage autolaunch.".into());
|
||||||
|
}
|
||||||
|
if let Err(e) = config::register_hotkeys(&conf.hotkeys) {
|
||||||
|
setup_errors.push(format!("{e}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// if session is empty, this is probably the first launch, so don't autohide
|
||||||
|
if !conf.start_minimized || is_first_launch {
|
||||||
|
app.get_window("main")
|
||||||
|
.ok_or(HandlerError::NoMainWindow)?
|
||||||
|
.show()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState::new(conf, session, srv, pool, setup_errors);
|
||||||
|
app.manage(state);
|
||||||
|
Ok(())
|
||||||
|
}
|
45
src-tauri/src/bin/creddy_cli.rs
Normal file
45
src-tauri/src/bin/creddy_cli.rs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// Windows isn't really amenable to having a single executable work as both a CLI and GUI app,
|
||||||
|
// so we just have a second binary for CLI usage
|
||||||
|
use creddy::{
|
||||||
|
cli,
|
||||||
|
errors::CliError,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
process::{self, Command},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args = cli::parser().get_matches();
|
||||||
|
if let Some(true) = args.get_one::<bool>("help") {
|
||||||
|
cli::parser().print_help().unwrap(); // if we can't print help we can't print an error
|
||||||
|
process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = match args.subcommand() {
|
||||||
|
None | Some(("run", _)) => launch_gui(),
|
||||||
|
Some(("show", m)) => cli::show(m),
|
||||||
|
Some(("exec", m)) => cli::exec(m),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
eprintln!("Error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn launch_gui() -> Result<(), CliError> {
|
||||||
|
let mut path = env::current_exe()?;
|
||||||
|
path.pop(); // bin dir
|
||||||
|
|
||||||
|
// binaries are colocated in dev, but not in production
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
path.pop(); // install dir
|
||||||
|
|
||||||
|
path.push("creddy.exe"); // exe in main install dir (aka gui exe)
|
||||||
|
|
||||||
|
Command::new(path).spawn()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
161
src-tauri/src/cli.rs
Normal file
161
src-tauri/src/cli.rs
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
use std::ffi::OsString;
|
||||||
|
use std::process::Command as ChildCommand;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
||||||
|
use clap::{
|
||||||
|
Command,
|
||||||
|
Arg,
|
||||||
|
ArgMatches,
|
||||||
|
ArgAction
|
||||||
|
};
|
||||||
|
use tokio::{
|
||||||
|
net::TcpStream,
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
use crate::app;
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::credentials::{BaseCredentials, SessionCredentials};
|
||||||
|
use crate::errors::*;
|
||||||
|
|
||||||
|
|
||||||
|
pub fn parser() -> Command<'static> {
|
||||||
|
Command::new("creddy")
|
||||||
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
|
.about("A friendly AWS credentials manager")
|
||||||
|
.subcommand(
|
||||||
|
Command::new("run")
|
||||||
|
.about("Launch Creddy")
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
Command::new("show")
|
||||||
|
.about("Fetch and display AWS credentials")
|
||||||
|
.arg(
|
||||||
|
Arg::new("base")
|
||||||
|
.short('b')
|
||||||
|
.long("base")
|
||||||
|
.action(ArgAction::SetTrue)
|
||||||
|
.help("Use base credentials instead of session credentials")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
Command::new("exec")
|
||||||
|
.about("Inject AWS credentials into the environment of another command")
|
||||||
|
.trailing_var_arg(true)
|
||||||
|
.arg(
|
||||||
|
Arg::new("base")
|
||||||
|
.short('b')
|
||||||
|
.long("base")
|
||||||
|
.action(ArgAction::SetTrue)
|
||||||
|
.help("Use base credentials instead of session credentials")
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::new("command")
|
||||||
|
.multiple_values(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn show(args: &ArgMatches) -> Result<(), CliError> {
|
||||||
|
let base = args.get_one("base").unwrap_or(&false);
|
||||||
|
let creds = get_credentials(*base)?;
|
||||||
|
println!("{creds}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
|
||||||
|
let base = *args.get_one("base").unwrap_or(&false);
|
||||||
|
let mut cmd_line = args.get_many("command")
|
||||||
|
.ok_or(ExecError::NoCommand)?;
|
||||||
|
|
||||||
|
let cmd_name: &String = cmd_line.next().unwrap(); // Clap guarantees that there will be at least one
|
||||||
|
let mut cmd = ChildCommand::new(cmd_name);
|
||||||
|
cmd.args(cmd_line);
|
||||||
|
|
||||||
|
if base {
|
||||||
|
let creds: BaseCredentials = serde_json::from_str(&get_credentials(base)?)
|
||||||
|
.map_err(|_| RequestError::InvalidJson)?;
|
||||||
|
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
||||||
|
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let creds: SessionCredentials = serde_json::from_str(&get_credentials(base)?)
|
||||||
|
.map_err(|_| RequestError::InvalidJson)?;
|
||||||
|
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
||||||
|
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
||||||
|
cmd.env("AWS_SESSION_TOKEN", creds.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
// cmd.exec() never returns if successful
|
||||||
|
let e = cmd.exec();
|
||||||
|
match e.kind() {
|
||||||
|
std::io::ErrorKind::NotFound => {
|
||||||
|
let name: OsString = cmd_name.into();
|
||||||
|
Err(ExecError::NotFound(name).into())
|
||||||
|
}
|
||||||
|
_ => Err(ExecError::ExecutionFailed(e).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let mut child = match cmd.spawn() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
let name: OsString = cmd_name.into();
|
||||||
|
return Err(ExecError::NotFound(name).into());
|
||||||
|
}
|
||||||
|
Err(e) => return Err(ExecError::ExecutionFailed(e).into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = child.wait()
|
||||||
|
.map_err(|e| ExecError::ExecutionFailed(e))?;
|
||||||
|
std::process::exit(status.code().unwrap_or(1));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn get_credentials(base: bool) -> Result<String, RequestError> {
|
||||||
|
let pool = app::connect_db().await?;
|
||||||
|
let config = AppConfig::load(&pool).await?;
|
||||||
|
let path = if base {"/creddy/base-credentials"} else {"/"};
|
||||||
|
|
||||||
|
let mut stream = TcpStream::connect((config.listen_addr, config.listen_port)).await?;
|
||||||
|
let req = format!("GET {path} HTTP/1.0\r\n\r\n");
|
||||||
|
stream.write_all(req.as_bytes()).await?;
|
||||||
|
|
||||||
|
// some day we'll have a proper HTTP parser
|
||||||
|
let mut buf = vec![0; 8192];
|
||||||
|
stream.read_to_end(&mut buf).await?;
|
||||||
|
|
||||||
|
let status = buf.split(|&c| &[c] == b" ")
|
||||||
|
.skip(1)
|
||||||
|
.next()
|
||||||
|
.ok_or(RequestError::MalformedHttpResponse)?;
|
||||||
|
|
||||||
|
if status != b"200" {
|
||||||
|
let s = String::from_utf8_lossy(status).to_string();
|
||||||
|
return Err(RequestError::Failed(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
let break_idx = buf.windows(4)
|
||||||
|
.position(|w| w == b"\r\n\r\n")
|
||||||
|
.ok_or(RequestError::MalformedHttpResponse)?;
|
||||||
|
let body = &buf[(break_idx + 4)..];
|
||||||
|
|
||||||
|
let creds_str = std::str::from_utf8(body)
|
||||||
|
.map_err(|_| RequestError::MalformedHttpResponse)?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if creds_str == "Denied!" {
|
||||||
|
return Err(RequestError::Rejected);
|
||||||
|
}
|
||||||
|
Ok(creds_str)
|
||||||
|
}
|
@ -1,9 +1,12 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
|
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
|
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
app::APP,
|
||||||
errors::*,
|
errors::*,
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
@ -13,12 +16,12 @@ use crate::{
|
|||||||
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
pub exe: String,
|
pub exe: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
|
async fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
|
||||||
let state = crate::APP.get().unwrap().state::<AppState>();
|
let state = APP.get().unwrap().state::<AppState>();
|
||||||
let AppConfig {
|
let AppConfig {
|
||||||
listen_addr: app_listen_addr,
|
listen_addr: app_listen_addr,
|
||||||
listen_port: app_listen_port,
|
listen_port: app_listen_port,
|
||||||
@ -60,7 +63,7 @@ pub async fn get_clients(local_port: u16) -> Result<Vec<Option<Client>>, ClientI
|
|||||||
|
|
||||||
let client = Client {
|
let client = Client {
|
||||||
pid: p,
|
pid: p,
|
||||||
exe: proc.exe().to_string_lossy().into_owned(),
|
exe: proc.exe().to_path_buf(),
|
||||||
};
|
};
|
||||||
clients.push(Some(client));
|
clients.push(Some(client));
|
||||||
}
|
}
|
||||||
|
@ -2,12 +2,44 @@ use std::net::Ipv4Addr;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use auto_launch::AutoLaunchBuilder;
|
use auto_launch::AutoLaunchBuilder;
|
||||||
|
use is_terminal::IsTerminal;
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
use tauri::{
|
||||||
|
Manager,
|
||||||
|
GlobalShortcutManager,
|
||||||
|
async_runtime as rt,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::errors::*;
|
use crate::errors::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TermConfig {
|
||||||
|
pub name: String,
|
||||||
|
// we call it exec because it isn't always the actual path,
|
||||||
|
// in some cases it's just the name and relies on path-searching
|
||||||
|
// it's a string because it can come from the frontend as json
|
||||||
|
pub exec: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
|
||||||
|
pub struct Hotkey {
|
||||||
|
pub keys: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
|
||||||
|
pub struct HotkeysConfig {
|
||||||
|
// tauri uses strings to represent keybinds, so we will as well
|
||||||
|
pub show_window: Hotkey,
|
||||||
|
pub launch_terminal: Hotkey,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
#[serde(default = "default_listen_addr")]
|
#[serde(default = "default_listen_addr")]
|
||||||
@ -20,6 +52,10 @@ pub struct AppConfig {
|
|||||||
pub start_minimized: bool,
|
pub start_minimized: bool,
|
||||||
#[serde(default = "default_start_on_login")]
|
#[serde(default = "default_start_on_login")]
|
||||||
pub start_on_login: bool,
|
pub start_on_login: bool,
|
||||||
|
#[serde(default = "default_term_config")]
|
||||||
|
pub terminal: TermConfig,
|
||||||
|
#[serde(default = "default_hotkey_config")]
|
||||||
|
pub hotkeys: HotkeysConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -31,6 +67,8 @@ impl Default for AppConfig {
|
|||||||
rehide_ms: default_rehide_ms(),
|
rehide_ms: default_rehide_ms(),
|
||||||
start_minimized: default_start_minimized(),
|
start_minimized: default_start_minimized(),
|
||||||
start_on_login: default_start_on_login(),
|
start_on_login: default_start_on_login(),
|
||||||
|
terminal: default_term_config(),
|
||||||
|
hotkeys: default_hotkey_config(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,16 +128,17 @@ pub fn set_auto_launch(is_configured: bool) -> Result<(), SetupError> {
|
|||||||
|
|
||||||
|
|
||||||
pub fn get_or_create_db_path() -> Result<PathBuf, DataDirError> {
|
pub fn get_or_create_db_path() -> Result<PathBuf, DataDirError> {
|
||||||
// debug_assertions doesn't always mean we are running in dev
|
|
||||||
if cfg!(debug_assertions) && std::env::var("HOME").is_ok() {
|
|
||||||
return Ok(PathBuf::from("./creddy.db"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut path = dirs::data_dir()
|
let mut path = dirs::data_dir()
|
||||||
.ok_or(DataDirError::NotFound)?;
|
.ok_or(DataDirError::NotFound)?;
|
||||||
|
path.push("Creddy");
|
||||||
|
|
||||||
std::fs::create_dir_all(&path)?;
|
std::fs::create_dir_all(&path)?;
|
||||||
|
if cfg!(debug_assertions) && std::io::stdout().is_terminal() {
|
||||||
|
path.push("creddy.dev.db");
|
||||||
|
}
|
||||||
|
else {
|
||||||
path.push("creddy.db");
|
path.push("creddy.db");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
@ -114,6 +153,91 @@ fn default_listen_port() -> u16 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn default_term_config() -> TermConfig {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let shell = if which::which("pwsh.exe").is_ok() {
|
||||||
|
"pwsh.exe".to_string()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
"powershell.exe".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (exec, args) = if cfg!(debug_assertions) {
|
||||||
|
("conhost.exe".to_string(), vec![shell.clone()])
|
||||||
|
} else {
|
||||||
|
(shell.clone(), vec![])
|
||||||
|
};
|
||||||
|
|
||||||
|
TermConfig { name: shell, exec, args }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
for bin in ["gnome-terminal", "konsole"] {
|
||||||
|
if let Ok(_) = which::which(bin) {
|
||||||
|
return TermConfig {
|
||||||
|
name: bin.into(),
|
||||||
|
exec: bin.into(),
|
||||||
|
args: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TermConfig {
|
||||||
|
name: "gnome-terminal".into(),
|
||||||
|
exec: "gnome-terminal".into(),
|
||||||
|
args: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn default_hotkey_config() -> HotkeysConfig {
|
||||||
|
HotkeysConfig {
|
||||||
|
show_window: Hotkey {keys: "alt+shift+C".into(), enabled: true},
|
||||||
|
launch_terminal: Hotkey {keys: "alt+shift+T".into(), enabled: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// note: will panic if called before APP is set
|
||||||
|
pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> tauri::Result<()> {
|
||||||
|
let app = crate::app::APP.get().unwrap();
|
||||||
|
|
||||||
|
let mut manager = app.global_shortcut_manager();
|
||||||
|
manager.unregister_all()?;
|
||||||
|
|
||||||
|
if hotkeys.show_window.enabled {
|
||||||
|
let handle = app.app_handle();
|
||||||
|
manager.register(
|
||||||
|
&hotkeys.show_window.keys,
|
||||||
|
move || {
|
||||||
|
handle.get_window("main")
|
||||||
|
.map(|w| w.show().error_popup("Failed to show"))
|
||||||
|
.ok_or(HandlerError::NoMainWindow)
|
||||||
|
.error_popup("No main window");
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if hotkeys.launch_terminal.enabled {
|
||||||
|
// register() doesn't take an async fn, so we have to use spawn
|
||||||
|
manager.register(
|
||||||
|
&hotkeys.launch_terminal.keys,
|
||||||
|
|| {
|
||||||
|
rt::spawn(async {
|
||||||
|
crate::terminal::launch(false)
|
||||||
|
.await
|
||||||
|
.error_popup("Failed to launch");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fn default_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST }
|
fn default_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST }
|
||||||
fn default_rehide_ms() -> u64 { 1000 }
|
fn default_rehide_ms() -> u64 { 1000 }
|
||||||
// start minimized and on login only in production mode
|
// start minimized and on login only in production mode
|
||||||
|
331
src-tauri/src/credentials.rs
Normal file
331
src-tauri/src/credentials.rs
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
use std::fmt::{self, Formatter};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use aws_smithy_types::date_time::{DateTime, Format};
|
||||||
|
use argon2::{
|
||||||
|
Argon2,
|
||||||
|
Algorithm,
|
||||||
|
Version,
|
||||||
|
ParamsBuilder,
|
||||||
|
password_hash::rand_core::{RngCore, OsRng},
|
||||||
|
};
|
||||||
|
use chacha20poly1305::{
|
||||||
|
XChaCha20Poly1305,
|
||||||
|
XNonce,
|
||||||
|
aead::{
|
||||||
|
Aead,
|
||||||
|
AeadCore,
|
||||||
|
KeyInit,
|
||||||
|
Error as AeadError,
|
||||||
|
generic_array::GenericArray,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use serde::{
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
Serializer,
|
||||||
|
Deserializer,
|
||||||
|
};
|
||||||
|
use serde::de::{self, Visitor};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
|
||||||
|
use crate::errors::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum Session {
|
||||||
|
Unlocked{
|
||||||
|
base: BaseCredentials,
|
||||||
|
session: SessionCredentials,
|
||||||
|
},
|
||||||
|
Locked(LockedCredentials),
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub async fn load(pool: &SqlitePool) -> Result<Self, SetupError> {
|
||||||
|
let res = sqlx::query!("SELECT * FROM credentials ORDER BY created_at desc")
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
let row = match res {
|
||||||
|
Some(r) => r,
|
||||||
|
None => {return Ok(Session::Empty);}
|
||||||
|
};
|
||||||
|
|
||||||
|
let salt: [u8; 32] = row.salt
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_e| SetupError::InvalidRecord)?;
|
||||||
|
let nonce = XNonce::from_exact_iter(row.nonce.into_iter())
|
||||||
|
.ok_or(SetupError::InvalidRecord)?;
|
||||||
|
|
||||||
|
let creds = LockedCredentials {
|
||||||
|
access_key_id: row.access_key_id,
|
||||||
|
secret_key_enc: row.secret_key_enc,
|
||||||
|
salt,
|
||||||
|
nonce,
|
||||||
|
};
|
||||||
|
Ok(Session::Locked(creds))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn renew_if_expired(&mut self) -> Result<bool, GetSessionError> {
|
||||||
|
match self {
|
||||||
|
Session::Unlocked{ref base, ref mut session} => {
|
||||||
|
if !session.is_expired() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
*session = SessionCredentials::from_base(base).await?;
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
Session::Locked(_) => Err(GetSessionError::CredentialsLocked),
|
||||||
|
Session::Empty => Err(GetSessionError::CredentialsEmpty),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_get(
|
||||||
|
&self
|
||||||
|
) -> Result<(&BaseCredentials, &SessionCredentials), GetCredentialsError> {
|
||||||
|
match self {
|
||||||
|
Self::Empty => Err(GetCredentialsError::Empty),
|
||||||
|
Self::Locked(_) => Err(GetCredentialsError::Locked),
|
||||||
|
Self::Unlocked{ ref base, ref session } => Ok((base, session))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct LockedCredentials {
|
||||||
|
pub access_key_id: String,
|
||||||
|
pub secret_key_enc: Vec<u8>,
|
||||||
|
pub salt: [u8; 32],
|
||||||
|
pub nonce: XNonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LockedCredentials {
|
||||||
|
pub async fn save(&self, pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO credentials (access_key_id, secret_key_enc, salt, nonce, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, strftime('%s'))"
|
||||||
|
)
|
||||||
|
.bind(&self.access_key_id)
|
||||||
|
.bind(&self.secret_key_enc)
|
||||||
|
.bind(&self.salt[..])
|
||||||
|
.bind(&self.nonce[..])
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt(&self, passphrase: &str) -> Result<BaseCredentials, UnlockError> {
|
||||||
|
let crypto = Crypto::new(passphrase, &self.salt)
|
||||||
|
.map_err(|e| CryptoError::Argon2(e))?;
|
||||||
|
let decrypted = crypto.decrypt(&self.nonce, &self.secret_key_enc)
|
||||||
|
.map_err(|e| CryptoError::Aead(e))?;
|
||||||
|
let secret_access_key = String::from_utf8(decrypted)
|
||||||
|
.map_err(|_| UnlockError::InvalidUtf8)?;
|
||||||
|
|
||||||
|
let creds = BaseCredentials {
|
||||||
|
access_key_id: self.access_key_id.clone(),
|
||||||
|
secret_access_key,
|
||||||
|
};
|
||||||
|
Ok(creds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct BaseCredentials {
|
||||||
|
pub access_key_id: String,
|
||||||
|
pub secret_access_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BaseCredentials {
|
||||||
|
pub fn encrypt(&self, passphrase: &str) -> Result<LockedCredentials, CryptoError> {
|
||||||
|
let salt = Crypto::salt();
|
||||||
|
let crypto = Crypto::new(passphrase, &salt)?;
|
||||||
|
let (nonce, secret_key_enc) = crypto.encrypt(self.secret_access_key.as_bytes())?;
|
||||||
|
|
||||||
|
let locked = LockedCredentials {
|
||||||
|
access_key_id: self.access_key_id.clone(),
|
||||||
|
secret_key_enc,
|
||||||
|
salt,
|
||||||
|
nonce,
|
||||||
|
};
|
||||||
|
Ok(locked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct SessionCredentials {
|
||||||
|
pub access_key_id: String,
|
||||||
|
pub secret_access_key: String,
|
||||||
|
pub token: String,
|
||||||
|
#[serde(serialize_with = "serialize_expiration")]
|
||||||
|
#[serde(deserialize_with = "deserialize_expiration")]
|
||||||
|
pub expiration: DateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionCredentials {
|
||||||
|
pub async fn from_base(base: &BaseCredentials) -> Result<Self, GetSessionError> {
|
||||||
|
let req_creds = aws_sdk_sts::Credentials::new(
|
||||||
|
&base.access_key_id,
|
||||||
|
&base.secret_access_key,
|
||||||
|
None, // token
|
||||||
|
None, //expiration
|
||||||
|
"Creddy", // "provider name" apparently
|
||||||
|
);
|
||||||
|
let config = aws_config::from_env()
|
||||||
|
.credentials_provider(req_creds)
|
||||||
|
.load()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = aws_sdk_sts::Client::new(&config);
|
||||||
|
let resp = client.get_session_token()
|
||||||
|
.duration_seconds(43_200)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let aws_session = resp.credentials().ok_or(GetSessionError::EmptyResponse)?;
|
||||||
|
|
||||||
|
let access_key_id = aws_session.access_key_id()
|
||||||
|
.ok_or(GetSessionError::EmptyResponse)?
|
||||||
|
.to_string();
|
||||||
|
let secret_access_key = aws_session.secret_access_key()
|
||||||
|
.ok_or(GetSessionError::EmptyResponse)?
|
||||||
|
.to_string();
|
||||||
|
let token = aws_session.session_token()
|
||||||
|
.ok_or(GetSessionError::EmptyResponse)?
|
||||||
|
.to_string();
|
||||||
|
let expiration = aws_session.expiration()
|
||||||
|
.ok_or(GetSessionError::EmptyResponse)?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let session_creds = SessionCredentials {
|
||||||
|
access_key_id,
|
||||||
|
secret_access_key,
|
||||||
|
token,
|
||||||
|
expiration,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
println!("Got new session:\n{}", serde_json::to_string(&session_creds).unwrap());
|
||||||
|
|
||||||
|
Ok(session_creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
let current_ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap() // doesn't panic because UNIX_EPOCH won't be later than now()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let expire_ts = self.expiration.secs();
|
||||||
|
let remaining = expire_ts - (current_ts as i64);
|
||||||
|
remaining < 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_expiration<S>(exp: &DateTime, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where S: Serializer
|
||||||
|
{
|
||||||
|
// this only fails if the d/t is out of range, which it can't be for this format
|
||||||
|
let time_str = exp.fmt(Format::DateTime).unwrap();
|
||||||
|
serializer.serialize_str(&time_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct DateTimeVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for DateTimeVisitor {
|
||||||
|
type Value = DateTime;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
|
||||||
|
write!(formatter, "an RFC 3339 UTC string, e.g. \"2014-01-05T10:17:34Z\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: de::Error>(self, v: &str) -> Result<DateTime, E> {
|
||||||
|
DateTime::from_str(v, Format::DateTime)
|
||||||
|
.map_err(|_| E::custom(format!("Invalid date/time: {v}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn deserialize_expiration<'de, D>(deserializer: D) -> Result<DateTime, D::Error>
|
||||||
|
where D: Deserializer<'de>
|
||||||
|
{
|
||||||
|
deserializer.deserialize_str(DateTimeVisitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct Crypto {
|
||||||
|
cipher: XChaCha20Poly1305,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Crypto {
|
||||||
|
/// Argon2 params rationale:
|
||||||
|
///
|
||||||
|
/// m_cost is measured in KiB, so 128 * 1024 gives us 128MiB.
|
||||||
|
/// This should roughly double the memory usage of the application
|
||||||
|
/// while deriving the key.
|
||||||
|
///
|
||||||
|
/// p_cost is irrelevant since (at present) there isn't any parallelism
|
||||||
|
/// implemented, so we leave it at 1.
|
||||||
|
///
|
||||||
|
/// With the above m_cost, t_cost = 8 results in about 800ms to derive
|
||||||
|
/// a key on my (somewhat older) CPU. This is probably overkill, but
|
||||||
|
/// given that it should only have to happen ~once a day for most
|
||||||
|
/// usage, it should be acceptable.
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
const MEM_COST: u32 = 128 * 1024;
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
const TIME_COST: u32 = 8;
|
||||||
|
|
||||||
|
/// But since this takes a million years without optimizations,
|
||||||
|
/// we turn it way down in debug builds.
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
const MEM_COST: u32 = 48 * 1024;
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
const TIME_COST: u32 = 1;
|
||||||
|
|
||||||
|
|
||||||
|
fn new(passphrase: &str, salt: &[u8]) -> argon2::Result<Crypto> {
|
||||||
|
let params = ParamsBuilder::new()
|
||||||
|
.m_cost(Self::MEM_COST)
|
||||||
|
.p_cost(1)
|
||||||
|
.t_cost(Self::TIME_COST)
|
||||||
|
.build()
|
||||||
|
.unwrap(); // only errors if the given params are invalid
|
||||||
|
|
||||||
|
let hasher = Argon2::new(
|
||||||
|
Algorithm::Argon2id,
|
||||||
|
Version::V0x13,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut key = [0; 32];
|
||||||
|
hasher.hash_password_into(passphrase.as_bytes(), &salt, &mut key)?;
|
||||||
|
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
|
||||||
|
Ok(Crypto { cipher })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn salt() -> [u8; 32] {
|
||||||
|
let mut salt = [0; 32];
|
||||||
|
OsRng.fill_bytes(&mut salt);
|
||||||
|
salt
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec<u8>), AeadError> {
|
||||||
|
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
|
||||||
|
let ciphertext = self.cipher.encrypt(&nonce, data)?;
|
||||||
|
Ok((nonce, ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt(&self, nonce: &XNonce, data: &[u8]) -> Result<Vec<u8>, AeadError> {
|
||||||
|
self.cipher.decrypt(nonce, data)
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::convert::AsRef;
|
use std::convert::AsRef;
|
||||||
|
use std::ffi::OsString;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use strum_macros::AsRefStr;
|
use strum_macros::AsRefStr;
|
||||||
|
|
||||||
@ -21,9 +22,10 @@ use serde::{Serialize, Serializer, ser::SerializeMap};
|
|||||||
|
|
||||||
pub trait ErrorPopup {
|
pub trait ErrorPopup {
|
||||||
fn error_popup(self, title: &str);
|
fn error_popup(self, title: &str);
|
||||||
|
fn error_popup_nowait(self, title: &str);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E: Error> ErrorPopup for Result<(), E> {
|
impl<E: std::fmt::Display> ErrorPopup for Result<(), E> {
|
||||||
fn error_popup(self, title: &str) {
|
fn error_popup(self, title: &str) {
|
||||||
if let Err(e) = self {
|
if let Err(e) = self {
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
@ -34,6 +36,14 @@ impl<E: Error> ErrorPopup for Result<(), E> {
|
|||||||
rx.recv().unwrap();
|
rx.recv().unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn error_popup_nowait(self, title: &str) {
|
||||||
|
if let Err(e) = self {
|
||||||
|
MessageDialogBuilder::new(title, format!("{e}"))
|
||||||
|
.kind(MessageDialogKind::Error)
|
||||||
|
.show(|_| {})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -57,8 +67,12 @@ where
|
|||||||
E: Error,
|
E: Error,
|
||||||
M: serde::ser::SerializeMap,
|
M: serde::ser::SerializeMap,
|
||||||
{
|
{
|
||||||
let src = err.source().map(|s| format!("{s}"));
|
let msg = err.source().map(|s| format!("{s}"));
|
||||||
map.serialize_entry("source", &src)
|
map.serialize_entry("msg", &msg)?;
|
||||||
|
map.serialize_entry("code", &None::<&str>)?;
|
||||||
|
map.serialize_entry("source", &None::<&str>)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -90,6 +104,8 @@ pub enum SetupError {
|
|||||||
ServerSetupError(#[from] std::io::Error),
|
ServerSetupError(#[from] std::io::Error),
|
||||||
#[error("Failed to resolve data directory: {0}")]
|
#[error("Failed to resolve data directory: {0}")]
|
||||||
DataDir(#[from] DataDirError),
|
DataDir(#[from] DataDirError),
|
||||||
|
#[error("Failed to register hotkeys: {0}")]
|
||||||
|
RegisterHotkeys(#[from] tauri::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -109,6 +125,8 @@ pub enum SendResponseError {
|
|||||||
NotFound,
|
NotFound,
|
||||||
#[error("The specified request was already closed by the client")]
|
#[error("The specified request was already closed by the client")]
|
||||||
Abandoned,
|
Abandoned,
|
||||||
|
#[error("A response has already been received for the specified request")]
|
||||||
|
Fulfilled,
|
||||||
#[error("Could not renew AWS sesssion: {0}")]
|
#[error("Could not renew AWS sesssion: {0}")]
|
||||||
SessionRenew(#[from] GetSessionError),
|
SessionRenew(#[from] GetSessionError),
|
||||||
}
|
}
|
||||||
@ -116,13 +134,13 @@ pub enum SendResponseError {
|
|||||||
|
|
||||||
// errors encountered while handling an HTTP request
|
// errors encountered while handling an HTTP request
|
||||||
#[derive(Debug, ThisError, AsRefStr)]
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
pub enum RequestError {
|
pub enum HandlerError {
|
||||||
#[error("Error writing to stream: {0}")]
|
#[error("Error writing to stream: {0}")]
|
||||||
StreamIOError(#[from] std::io::Error),
|
StreamIOError(#[from] std::io::Error),
|
||||||
// #[error("Received invalid UTF-8 in request")]
|
// #[error("Received invalid UTF-8 in request")]
|
||||||
// InvalidUtf8,
|
// InvalidUtf8,
|
||||||
#[error("HTTP request malformed")]
|
#[error("HTTP request malformed")]
|
||||||
BadRequest,
|
BadRequest(Vec<u8>),
|
||||||
#[error("HTTP request too large")]
|
#[error("HTTP request too large")]
|
||||||
RequestTooLarge,
|
RequestTooLarge,
|
||||||
#[error("Error accessing credentials: {0}")]
|
#[error("Error accessing credentials: {0}")]
|
||||||
@ -164,8 +182,8 @@ pub enum UnlockError {
|
|||||||
NotLocked,
|
NotLocked,
|
||||||
#[error("No saved credentials were found")]
|
#[error("No saved credentials were found")]
|
||||||
NoCredentials,
|
NoCredentials,
|
||||||
#[error("Invalid passphrase")]
|
#[error(transparent)]
|
||||||
BadPassphrase,
|
Crypto(#[from] CryptoError),
|
||||||
#[error("Data was found to be corrupt after decryption")]
|
#[error("Data was found to be corrupt after decryption")]
|
||||||
InvalidUtf8, // Somehow we got invalid utf-8 even though decryption succeeded
|
InvalidUtf8, // Somehow we got invalid utf-8 even though decryption succeeded
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
@ -175,6 +193,15 @@ pub enum UnlockError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum CryptoError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Argon2(#[from] argon2::Error),
|
||||||
|
#[error("Invalid passphrase")] // I think this is the only way decryption fails
|
||||||
|
Aead(#[from] chacha20poly1305::aead::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Errors encountered while trying to figure out who's on the other end of a request
|
// Errors encountered while trying to figure out who's on the other end of a request
|
||||||
#[derive(Debug, ThisError, AsRefStr)]
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
pub enum ClientInfoError {
|
pub enum ClientInfoError {
|
||||||
@ -185,6 +212,62 @@ pub enum ClientInfoError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Errors encountered while requesting credentials via CLI (creddy show, creddy exec)
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum RequestError {
|
||||||
|
#[error("Credentials request failed: HTTP {0}")]
|
||||||
|
Failed(String),
|
||||||
|
#[error("Credentials request was rejected")]
|
||||||
|
Rejected,
|
||||||
|
#[error("Couldn't interpret the server's response")]
|
||||||
|
MalformedHttpResponse,
|
||||||
|
#[error("The server did not respond with valid JSON")]
|
||||||
|
InvalidJson,
|
||||||
|
#[error("Error reading/writing stream: {0}")]
|
||||||
|
StreamIOError(#[from] std::io::Error),
|
||||||
|
#[error("Error loading configuration data: {0}")]
|
||||||
|
Setup(#[from] SetupError),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum CliError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Request(#[from] RequestError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Exec(#[from] ExecError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Errors encountered while trying to launch a child process
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum ExecError {
|
||||||
|
#[error("Please specify a command")]
|
||||||
|
NoCommand,
|
||||||
|
#[error("Executable not found: {0:?}")]
|
||||||
|
NotFound(OsString),
|
||||||
|
#[error("Failed to execute command: {0}")]
|
||||||
|
ExecutionFailed(#[from] std::io::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
GetCredentials(#[from] GetCredentialsError),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum LaunchTerminalError {
|
||||||
|
#[error("Could not discover main window")]
|
||||||
|
NoMainWindow,
|
||||||
|
#[error("Failed to communicate with main Creddy window")]
|
||||||
|
IpcFailed(#[from] tauri::Error),
|
||||||
|
#[error("Failed to launch terminal: {0}")]
|
||||||
|
Exec(#[from] ExecError),
|
||||||
|
#[error(transparent)]
|
||||||
|
GetCredentials(#[from] GetCredentialsError),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// Serialize implementations
|
// Serialize implementations
|
||||||
// =========================
|
// =========================
|
||||||
@ -210,15 +293,15 @@ impl_serialize_basic!(GetCredentialsError);
|
|||||||
impl_serialize_basic!(ClientInfoError);
|
impl_serialize_basic!(ClientInfoError);
|
||||||
|
|
||||||
|
|
||||||
impl Serialize for RequestError {
|
impl Serialize for HandlerError {
|
||||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
let mut map = serializer.serialize_map(None)?;
|
let mut map = serializer.serialize_map(None)?;
|
||||||
map.serialize_entry("code", self.as_ref())?;
|
map.serialize_entry("code", self.as_ref())?;
|
||||||
map.serialize_entry("msg", &format!("{self}"))?;
|
map.serialize_entry("msg", &format!("{self}"))?;
|
||||||
|
|
||||||
match self {
|
match self {
|
||||||
RequestError::NoCredentials(src) => map.serialize_entry("source", &src)?,
|
HandlerError::NoCredentials(src) => map.serialize_entry("source", &src)?,
|
||||||
RequestError::ClientInfo(src) => map.serialize_entry("source", &src)?,
|
HandlerError::ClientInfo(src) => map.serialize_entry("source", &src)?,
|
||||||
_ => serialize_upstream_err(self, &mut map)?,
|
_ => serialize_upstream_err(self, &mut map)?,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -275,3 +358,33 @@ impl Serialize for UnlockError {
|
|||||||
map.end()
|
map.end()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl Serialize for ExecError {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let mut map = serializer.serialize_map(None)?;
|
||||||
|
map.serialize_entry("code", self.as_ref())?;
|
||||||
|
map.serialize_entry("msg", &format!("{self}"))?;
|
||||||
|
|
||||||
|
match self {
|
||||||
|
ExecError::GetCredentials(src) => map.serialize_entry("source", &src)?,
|
||||||
|
_ => serialize_upstream_err(self, &mut map)?,
|
||||||
|
}
|
||||||
|
map.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl Serialize for LaunchTerminalError {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let mut map = serializer.serialize_map(None)?;
|
||||||
|
map.serialize_entry("code", self.as_ref())?;
|
||||||
|
map.serialize_entry("msg", &format!("{self}"))?;
|
||||||
|
|
||||||
|
match self {
|
||||||
|
LaunchTerminalError::Exec(src) => map.serialize_entry("source", &src)?,
|
||||||
|
_ => serialize_upstream_err(self, &mut map)?,
|
||||||
|
}
|
||||||
|
map.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,16 +1,19 @@
|
|||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::errors::*;
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
|
use crate::credentials::{Session,BaseCredentials};
|
||||||
|
use crate::errors::*;
|
||||||
use crate::clientinfo::Client;
|
use crate::clientinfo::Client;
|
||||||
use crate::state::{AppState, Session, BaseCredentials};
|
use crate::state::AppState;
|
||||||
|
use crate::terminal;
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Request {
|
pub struct Request {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub clients: Vec<Option<Client>>,
|
pub clients: Vec<Option<Client>>,
|
||||||
|
pub base: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -58,7 +61,7 @@ pub async fn save_credentials(
|
|||||||
passphrase: String,
|
passphrase: String,
|
||||||
app_state: State<'_, AppState>
|
app_state: State<'_, AppState>
|
||||||
) -> Result<(), UnlockError> {
|
) -> Result<(), UnlockError> {
|
||||||
app_state.save_creds(credentials, &passphrase).await
|
app_state.new_creds(credentials, &passphrase).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -76,3 +79,15 @@ pub async fn save_config(config: AppConfig, app_state: State<'_, AppState>) -> R
|
|||||||
.map_err(|e| format!("Error saving config: {e}"))?;
|
.map_err(|e| format!("Error saving config: {e}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn launch_terminal(base: bool) -> Result<(), LaunchTerminalError> {
|
||||||
|
terminal::launch(base).await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_setup_errors(app_state: State<'_, AppState>) -> Result<Vec<String>, ()> {
|
||||||
|
Ok(app_state.setup_errors.clone())
|
||||||
|
}
|
||||||
|
11
src-tauri/src/lib.rs
Normal file
11
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod cli;
|
||||||
|
mod config;
|
||||||
|
mod credentials;
|
||||||
|
pub mod errors;
|
||||||
|
mod clientinfo;
|
||||||
|
mod ipc;
|
||||||
|
mod state;
|
||||||
|
mod server;
|
||||||
|
mod terminal;
|
||||||
|
mod tray;
|
@ -2,98 +2,26 @@
|
|||||||
all(not(debug_assertions), target_os = "windows"),
|
all(not(debug_assertions), target_os = "windows"),
|
||||||
windows_subsystem = "windows"
|
windows_subsystem = "windows"
|
||||||
)]
|
)]
|
||||||
use std::error::Error;
|
|
||||||
|
|
||||||
use once_cell::sync::OnceCell;
|
use creddy::{
|
||||||
use sqlx::{
|
app,
|
||||||
SqlitePool,
|
cli,
|
||||||
sqlite::SqlitePoolOptions,
|
errors::ErrorPopup,
|
||||||
sqlite::SqliteConnectOptions,
|
|
||||||
};
|
};
|
||||||
use tauri::{
|
|
||||||
App,
|
|
||||||
AppHandle,
|
|
||||||
Manager,
|
|
||||||
async_runtime as rt,
|
|
||||||
};
|
|
||||||
|
|
||||||
mod config;
|
|
||||||
mod errors;
|
|
||||||
mod clientinfo;
|
|
||||||
mod ipc;
|
|
||||||
mod state;
|
|
||||||
mod server;
|
|
||||||
mod tray;
|
|
||||||
|
|
||||||
use config::AppConfig;
|
|
||||||
use server::Server;
|
|
||||||
use errors::*;
|
|
||||||
use state::AppState;
|
|
||||||
|
|
||||||
|
|
||||||
pub static APP: OnceCell<AppHandle> = OnceCell::new();
|
|
||||||
|
|
||||||
|
|
||||||
async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
|
|
||||||
APP.set(app.handle()).unwrap();
|
|
||||||
|
|
||||||
let conn_opts = SqliteConnectOptions::new()
|
|
||||||
.filename(config::get_or_create_db_path()?)
|
|
||||||
.create_if_missing(true);
|
|
||||||
let pool_opts = SqlitePoolOptions::new();
|
|
||||||
let pool: SqlitePool = pool_opts.connect_with(conn_opts).await?;
|
|
||||||
sqlx::migrate!().run(&pool).await?;
|
|
||||||
|
|
||||||
let conf = AppConfig::load(&pool).await?;
|
|
||||||
let session = AppState::load_creds(&pool).await?;
|
|
||||||
let srv = Server::new(conf.listen_addr, conf.listen_port, app.handle()).await?;
|
|
||||||
|
|
||||||
config::set_auto_launch(conf.start_on_login)?;
|
|
||||||
if !conf.start_minimized {
|
|
||||||
app.get_window("main")
|
|
||||||
.ok_or(RequestError::NoMainWindow)?
|
|
||||||
.show()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = AppState::new(conf, session, srv, pool);
|
|
||||||
app.manage(state);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fn run() -> tauri::Result<()> {
|
|
||||||
tauri::Builder::default()
|
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
|
||||||
app.get_window("main")
|
|
||||||
.map(|w| w.show().error_popup("Failed to show main window"));
|
|
||||||
}))
|
|
||||||
.system_tray(tray::create())
|
|
||||||
.on_system_tray_event(tray::handle_event)
|
|
||||||
.invoke_handler(tauri::generate_handler![
|
|
||||||
ipc::unlock,
|
|
||||||
ipc::respond,
|
|
||||||
ipc::get_session_status,
|
|
||||||
ipc::save_credentials,
|
|
||||||
ipc::get_config,
|
|
||||||
ipc::save_config,
|
|
||||||
])
|
|
||||||
.setup(|app| rt::block_on(setup(app)))
|
|
||||||
.build(tauri::generate_context!())?
|
|
||||||
.run(|app, run_event| match run_event {
|
|
||||||
tauri::RunEvent::WindowEvent { label, event, .. } => match event {
|
|
||||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
|
||||||
let _ = app.get_window(&label).map(|w| w.hide());
|
|
||||||
api.prevent_close();
|
|
||||||
}
|
|
||||||
_ => ()
|
|
||||||
}
|
|
||||||
_ => ()
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
run().error_popup("Creddy failed to start");
|
let res = match cli::parser().get_matches().subcommand() {
|
||||||
|
None | Some(("run", _)) => {
|
||||||
|
app::run().error_popup("Creddy failed to start");
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
Some(("show", m)) => cli::show(m),
|
||||||
|
Some(("exec", m)) => cli::exec(m),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
eprintln!("Error: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ use tokio::net::{
|
|||||||
TcpStream,
|
TcpStream,
|
||||||
};
|
};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot::{self, Sender, Receiver};
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
@ -23,24 +23,55 @@ use crate::ipc::{Request, Approval};
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RequestWaiter {
|
||||||
|
pub rehide_after: bool,
|
||||||
|
pub sender: Option<Sender<Approval>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestWaiter {
|
||||||
|
pub fn notify(&mut self, approval: Approval) -> Result<(), SendResponseError> {
|
||||||
|
let chan = self.sender
|
||||||
|
.take()
|
||||||
|
.ok_or(SendResponseError::Fulfilled)?;
|
||||||
|
|
||||||
|
chan.send(approval)
|
||||||
|
.map_err(|_| SendResponseError::Abandoned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
struct Handler {
|
struct Handler {
|
||||||
request_id: u64,
|
request_id: u64,
|
||||||
stream: TcpStream,
|
stream: TcpStream,
|
||||||
receiver: Option<oneshot::Receiver<Approval>>,
|
rehide_after: bool,
|
||||||
|
receiver: Option<Receiver<Approval>>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Handler {
|
impl Handler {
|
||||||
async fn new(stream: TcpStream, app: AppHandle) -> Self {
|
async fn new(stream: TcpStream, app: AppHandle) -> Result<Self, HandlerError> {
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
|
|
||||||
|
// determine whether we should re-hide the window after handling this request
|
||||||
|
let is_currently_visible = app.get_window("main")
|
||||||
|
.ok_or(HandlerError::NoMainWindow)?
|
||||||
|
.is_visible()?;
|
||||||
|
let rehide_after = state.current_rehide_status()
|
||||||
|
.await
|
||||||
|
.unwrap_or(!is_currently_visible);
|
||||||
|
|
||||||
let (chan_send, chan_recv) = oneshot::channel();
|
let (chan_send, chan_recv) = oneshot::channel();
|
||||||
let request_id = state.register_request(chan_send).await;
|
let waiter = RequestWaiter {rehide_after, sender: Some(chan_send)};
|
||||||
Handler {
|
let request_id = state.register_request(waiter).await;
|
||||||
|
let handler = Handler {
|
||||||
request_id,
|
request_id,
|
||||||
stream,
|
stream,
|
||||||
|
rehide_after,
|
||||||
receiver: Some(chan_recv),
|
receiver: Some(chan_recv),
|
||||||
app
|
app
|
||||||
}
|
};
|
||||||
|
Ok(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle(mut self) {
|
async fn handle(mut self) {
|
||||||
@ -51,25 +82,37 @@ impl Handler {
|
|||||||
state.unregister_request(self.request_id).await;
|
state.unregister_request(self.request_id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn try_handle(&mut self) -> Result<(), RequestError> {
|
async fn try_handle(&mut self) -> Result<(), HandlerError> {
|
||||||
let _ = self.recv_request().await?;
|
let req_path = self.recv_request().await?;
|
||||||
let clients = self.get_clients().await?;
|
let clients = self.get_clients().await?;
|
||||||
if self.includes_banned(&clients).await {
|
if self.includes_banned(&clients).await {
|
||||||
self.stream.write(b"HTTP/1.0 403 Access Denied\r\n\r\n").await?;
|
self.stream.write(b"HTTP/1.0 403 Access Denied\r\n\r\n").await?;
|
||||||
return Ok(())
|
return Ok(())
|
||||||
}
|
}
|
||||||
|
let base = req_path == b"/creddy/base-credentials";
|
||||||
|
|
||||||
let req = Request {id: self.request_id, clients};
|
let req = Request {id: self.request_id, clients, base};
|
||||||
self.app.emit_all("credentials-request", &req)?;
|
self.app.emit_all("credentials-request", &req)?;
|
||||||
let starting_visibility = self.show_window()?;
|
self.show_window()?;
|
||||||
|
|
||||||
match self.wait_for_response().await? {
|
match self.wait_for_response().await? {
|
||||||
Approval::Approved => self.send_credentials().await?,
|
Approval::Approved => {
|
||||||
|
let state = self.app.state::<AppState>();
|
||||||
|
let creds = if base {
|
||||||
|
state.serialize_base_creds().await?
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
state.serialize_session_creds().await?
|
||||||
|
};
|
||||||
|
self.send_body(creds.as_bytes()).await?;
|
||||||
|
},
|
||||||
Approval::Denied => {
|
Approval::Denied => {
|
||||||
let state = self.app.state::<AppState>();
|
let state = self.app.state::<AppState>();
|
||||||
for client in req.clients {
|
for client in req.clients {
|
||||||
state.add_ban(client).await;
|
state.add_ban(client).await;
|
||||||
}
|
}
|
||||||
|
self.send_body(b"Denied!").await?;
|
||||||
|
self.stream.shutdown().await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,36 +125,39 @@ impl Handler {
|
|||||||
};
|
};
|
||||||
sleep(delay).await;
|
sleep(delay).await;
|
||||||
|
|
||||||
if !starting_visibility && state.req_count().await == 0 {
|
if self.rehide_after && state.req_count().await == 1 {
|
||||||
let window = self.app.get_window("main").ok_or(RequestError::NoMainWindow)?;
|
self.app
|
||||||
window.hide()?;
|
.get_window("main")
|
||||||
|
.ok_or(HandlerError::NoMainWindow)?
|
||||||
|
.hide()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recv_request(&mut self) -> Result<Vec<u8>, RequestError> {
|
async fn recv_request(&mut self) -> Result<Vec<u8>, HandlerError> {
|
||||||
let mut buf = vec![0; 8192]; // it's what tokio's BufReader uses
|
let mut buf = vec![0; 8192]; // it's what tokio's BufReader uses
|
||||||
let mut n = 0;
|
let mut n = 0;
|
||||||
loop {
|
loop {
|
||||||
n += self.stream.read(&mut buf[n..]).await?;
|
n += self.stream.read(&mut buf[n..]).await?;
|
||||||
if n >= 4 && &buf[(n - 4)..n] == b"\r\n\r\n" {break;}
|
if n >= 4 && &buf[(n - 4)..n] == b"\r\n\r\n" {break;}
|
||||||
if n == buf.len() {return Err(RequestError::RequestTooLarge);}
|
if n == buf.len() {return Err(HandlerError::RequestTooLarge);}
|
||||||
}
|
|
||||||
|
|
||||||
if cfg!(debug_assertions) {
|
|
||||||
println!("{}", std::str::from_utf8(&buf).unwrap());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = buf.split(|&c| &[c] == b" ")
|
let path = buf.split(|&c| &[c] == b" ")
|
||||||
.skip(1)
|
.skip(1)
|
||||||
.next()
|
.next()
|
||||||
.ok_or(RequestError::BadRequest(buf))?;
|
.ok_or(HandlerError::BadRequest(buf.clone()))?;
|
||||||
|
|
||||||
Ok(buf)
|
#[cfg(debug_assertions)] {
|
||||||
|
println!("Path: {}", std::str::from_utf8(&path).unwrap());
|
||||||
|
println!("{}", std::str::from_utf8(&buf).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_clients(&self) -> Result<Vec<Option<Client>>, RequestError> {
|
Ok(path.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_clients(&self) -> Result<Vec<Option<Client>>, HandlerError> {
|
||||||
let peer_addr = match self.stream.peer_addr()? {
|
let peer_addr = match self.stream.peer_addr()? {
|
||||||
SocketAddr::V4(addr) => addr,
|
SocketAddr::V4(addr) => addr,
|
||||||
_ => unreachable!(), // we only listen on IPv4
|
_ => unreachable!(), // we only listen on IPv4
|
||||||
@ -130,18 +176,17 @@ impl Handler {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_window(&self) -> Result<bool, RequestError> {
|
fn show_window(&self) -> Result<(), HandlerError> {
|
||||||
let window = self.app.get_window("main").ok_or(RequestError::NoMainWindow)?;
|
let window = self.app.get_window("main").ok_or(HandlerError::NoMainWindow)?;
|
||||||
let starting_visibility = window.is_visible()?;
|
if !window.is_visible()? {
|
||||||
if !starting_visibility {
|
|
||||||
window.unminimize()?;
|
window.unminimize()?;
|
||||||
window.show()?;
|
window.show()?;
|
||||||
}
|
}
|
||||||
window.set_focus()?;
|
window.set_focus()?;
|
||||||
Ok(starting_visibility)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_response(&mut self) -> Result<Approval, RequestError> {
|
async fn wait_for_response(&mut self) -> Result<Approval, HandlerError> {
|
||||||
self.stream.write(b"HTTP/1.0 200 OK\r\n").await?;
|
self.stream.write(b"HTTP/1.0 200 OK\r\n").await?;
|
||||||
self.stream.write(b"Content-Type: application/json\r\n").await?;
|
self.stream.write(b"Content-Type: application/json\r\n").await?;
|
||||||
self.stream.write(b"X-Creddy-delaying-tactic: ").await?;
|
self.stream.write(b"X-Creddy-delaying-tactic: ").await?;
|
||||||
@ -164,15 +209,12 @@ impl Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_credentials(&mut self) -> Result<(), RequestError> {
|
async fn send_body(&mut self, body: &[u8]) -> Result<(), HandlerError> {
|
||||||
let state = self.app.state::<AppState>();
|
|
||||||
let creds = state.serialize_session_creds().await?;
|
|
||||||
|
|
||||||
self.stream.write(b"\r\nContent-Length: ").await?;
|
self.stream.write(b"\r\nContent-Length: ").await?;
|
||||||
self.stream.write(creds.as_bytes().len().to_string().as_bytes()).await?;
|
self.stream.write(body.len().to_string().as_bytes()).await?;
|
||||||
self.stream.write(b"\r\n\r\n").await?;
|
|
||||||
self.stream.write(creds.as_bytes()).await?;
|
|
||||||
self.stream.write(b"\r\n\r\n").await?;
|
self.stream.write(b"\r\n\r\n").await?;
|
||||||
|
self.stream.write(body).await?;
|
||||||
|
self.stream.shutdown().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -221,12 +263,12 @@ impl Server {
|
|||||||
loop {
|
loop {
|
||||||
match listener.accept().await {
|
match listener.accept().await {
|
||||||
Ok((stream, _)) => {
|
Ok((stream, _)) => {
|
||||||
let handler = Handler::new(stream, app_handle.app_handle()).await;
|
match Handler::new(stream, app_handle.app_handle()).await {
|
||||||
rt::spawn(handler.handle());
|
Ok(handler) => { rt::spawn(handler.handle()); }
|
||||||
},
|
Err(e) => { eprintln!("Error handling request: {e}"); }
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error accepting connection: {e}");
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
Err(e) => { eprintln!("Error accepting connection: {e}"); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,88 +1,25 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::time::{
|
use std::time::Duration;
|
||||||
Duration,
|
|
||||||
SystemTime,
|
|
||||||
UNIX_EPOCH
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
use aws_smithy_types::date_time::{
|
|
||||||
DateTime as AwsDateTime,
|
|
||||||
Format as AwsDateTimeFormat,
|
|
||||||
};
|
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::oneshot::Sender,
|
|
||||||
sync::RwLock,
|
sync::RwLock,
|
||||||
time::sleep,
|
time::sleep,
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use sodiumoxide::crypto::{
|
|
||||||
pwhash,
|
|
||||||
pwhash::Salt,
|
|
||||||
secretbox,
|
|
||||||
secretbox::{Nonce, Key}
|
|
||||||
};
|
|
||||||
use tauri::async_runtime as runtime;
|
use tauri::async_runtime as runtime;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use serde::Serializer;
|
|
||||||
|
|
||||||
|
use crate::app::APP;
|
||||||
|
use crate::credentials::{
|
||||||
|
Session,
|
||||||
|
BaseCredentials,
|
||||||
|
SessionCredentials,
|
||||||
|
};
|
||||||
use crate::{config, config::AppConfig};
|
use crate::{config, config::AppConfig};
|
||||||
use crate::ipc;
|
use crate::ipc::{self, Approval};
|
||||||
use crate::clientinfo::Client;
|
use crate::clientinfo::Client;
|
||||||
use crate::errors::*;
|
use crate::errors::*;
|
||||||
use crate::server::Server;
|
use crate::server::{Server, RequestWaiter};
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "PascalCase")]
|
|
||||||
pub struct BaseCredentials {
|
|
||||||
access_key_id: String,
|
|
||||||
secret_access_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "PascalCase")]
|
|
||||||
pub struct SessionCredentials {
|
|
||||||
access_key_id: String,
|
|
||||||
secret_access_key: String,
|
|
||||||
token: String,
|
|
||||||
#[serde(serialize_with = "serialize_expiration")]
|
|
||||||
expiration: AwsDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionCredentials {
|
|
||||||
fn is_expired(&self) -> bool {
|
|
||||||
let current_ts = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap() // doesn't panic because UNIX_EPOCH won't be later than now()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let expire_ts = self.expiration.secs();
|
|
||||||
let remaining = expire_ts - (current_ts as i64);
|
|
||||||
remaining < 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct LockedCredentials {
|
|
||||||
access_key_id: String,
|
|
||||||
secret_key_enc: Vec<u8>,
|
|
||||||
salt: Salt,
|
|
||||||
nonce: Nonce,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum Session {
|
|
||||||
Unlocked{
|
|
||||||
base: BaseCredentials,
|
|
||||||
session: SessionCredentials,
|
|
||||||
},
|
|
||||||
Locked(LockedCredentials),
|
|
||||||
Empty,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@ -90,76 +27,41 @@ pub struct AppState {
|
|||||||
pub config: RwLock<AppConfig>,
|
pub config: RwLock<AppConfig>,
|
||||||
pub session: RwLock<Session>,
|
pub session: RwLock<Session>,
|
||||||
pub request_count: RwLock<u64>,
|
pub request_count: RwLock<u64>,
|
||||||
pub open_requests: RwLock<HashMap<u64, Sender<ipc::Approval>>>,
|
pub waiting_requests: RwLock<HashMap<u64, RequestWaiter>>,
|
||||||
|
pub pending_terminal_request: RwLock<bool>,
|
||||||
pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
|
pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
|
||||||
|
// setup_errors is never modified and so doesn't need to be wrapped in RwLock
|
||||||
|
pub setup_errors: Vec<String>,
|
||||||
server: RwLock<Server>,
|
server: RwLock<Server>,
|
||||||
pool: sqlx::SqlitePool,
|
pool: sqlx::SqlitePool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(config: AppConfig, session: Session, server: Server, pool: SqlitePool) -> AppState {
|
pub fn new(
|
||||||
|
config: AppConfig,
|
||||||
|
session: Session,
|
||||||
|
server: Server,
|
||||||
|
pool: SqlitePool,
|
||||||
|
setup_errors: Vec<String>,
|
||||||
|
) -> AppState {
|
||||||
AppState {
|
AppState {
|
||||||
config: RwLock::new(config),
|
config: RwLock::new(config),
|
||||||
session: RwLock::new(session),
|
session: RwLock::new(session),
|
||||||
request_count: RwLock::new(0),
|
request_count: RwLock::new(0),
|
||||||
open_requests: RwLock::new(HashMap::new()),
|
waiting_requests: RwLock::new(HashMap::new()),
|
||||||
|
pending_terminal_request: RwLock::new(false),
|
||||||
bans: RwLock::new(HashSet::new()),
|
bans: RwLock::new(HashSet::new()),
|
||||||
|
setup_errors,
|
||||||
server: RwLock::new(server),
|
server: RwLock::new(server),
|
||||||
pool,
|
pool,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_creds(pool: &SqlitePool) -> Result<Session, SetupError> {
|
pub async fn new_creds(&self, base_creds: BaseCredentials, passphrase: &str) -> Result<(), UnlockError> {
|
||||||
let res = sqlx::query!("SELECT * FROM credentials ORDER BY created_at desc")
|
let locked = base_creds.encrypt(passphrase)?;
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
let row = match res {
|
|
||||||
Some(r) => r,
|
|
||||||
None => {return Ok(Session::Empty);}
|
|
||||||
};
|
|
||||||
|
|
||||||
let salt_buf: [u8; 32] = row.salt
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_e| SetupError::InvalidRecord)?;
|
|
||||||
let nonce_buf: [u8; 24] = row.nonce
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_e| SetupError::InvalidRecord)?;
|
|
||||||
|
|
||||||
let creds = LockedCredentials {
|
|
||||||
access_key_id: row.access_key_id,
|
|
||||||
secret_key_enc: row.secret_key_enc,
|
|
||||||
salt: Salt(salt_buf),
|
|
||||||
nonce: Nonce(nonce_buf),
|
|
||||||
};
|
|
||||||
Ok(Session::Locked(creds))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save_creds(&self, creds: BaseCredentials, passphrase: &str) -> Result<(), UnlockError> {
|
|
||||||
let BaseCredentials {access_key_id, secret_access_key} = creds;
|
|
||||||
|
|
||||||
// do this first so that if it fails we don't save bad credentials
|
// do this first so that if it fails we don't save bad credentials
|
||||||
self.new_session(&access_key_id, &secret_access_key).await?;
|
self.new_session(base_creds).await?;
|
||||||
|
locked.save(&self.pool).await?;
|
||||||
let salt = pwhash::gen_salt();
|
|
||||||
let mut key_buf = [0; secretbox::KEYBYTES];
|
|
||||||
pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), &salt).unwrap();
|
|
||||||
let key = Key(key_buf);
|
|
||||||
// not sure we need both salt AND nonce given that we generate a
|
|
||||||
// fresh salt every time we encrypt, but better safe than sorry
|
|
||||||
let nonce = secretbox::gen_nonce();
|
|
||||||
let secret_key_enc = secretbox::seal(secret_access_key.as_bytes(), &nonce, &key);
|
|
||||||
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO credentials (access_key_id, secret_key_enc, salt, nonce, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, strftime('%s'))"
|
|
||||||
)
|
|
||||||
.bind(&access_key_id)
|
|
||||||
.bind(&secret_key_enc)
|
|
||||||
.bind(&salt.0[0..])
|
|
||||||
.bind(&nonce.0[0..])
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -167,54 +69,69 @@ impl AppState {
|
|||||||
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
|
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
|
||||||
let mut live_config = self.config.write().await;
|
let mut live_config = self.config.write().await;
|
||||||
|
|
||||||
|
// update autostart if necessary
|
||||||
if new_config.start_on_login != live_config.start_on_login {
|
if new_config.start_on_login != live_config.start_on_login {
|
||||||
config::set_auto_launch(new_config.start_on_login)?;
|
config::set_auto_launch(new_config.start_on_login)?;
|
||||||
}
|
}
|
||||||
|
// rebind socket if necessary
|
||||||
if new_config.listen_addr != live_config.listen_addr
|
if new_config.listen_addr != live_config.listen_addr
|
||||||
|| new_config.listen_port != live_config.listen_port
|
|| new_config.listen_port != live_config.listen_port
|
||||||
{
|
{
|
||||||
let mut sv = self.server.write().await;
|
let mut sv = self.server.write().await;
|
||||||
sv.rebind(new_config.listen_addr, new_config.listen_port).await?;
|
sv.rebind(new_config.listen_addr, new_config.listen_port).await?;
|
||||||
}
|
}
|
||||||
|
// re-register hotkeys if necessary
|
||||||
|
if new_config.hotkeys.show_window != live_config.hotkeys.show_window
|
||||||
|
|| new_config.hotkeys.launch_terminal != live_config.hotkeys.launch_terminal
|
||||||
|
{
|
||||||
|
config::register_hotkeys(&new_config.hotkeys)?;
|
||||||
|
}
|
||||||
|
|
||||||
new_config.save(&self.pool).await?;
|
new_config.save(&self.pool).await?;
|
||||||
*live_config = new_config;
|
*live_config = new_config;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
|
pub async fn register_request(&self, waiter: RequestWaiter) -> u64 {
|
||||||
let count = {
|
let count = {
|
||||||
let mut c = self.request_count.write().await;
|
let mut c = self.request_count.write().await;
|
||||||
*c += 1;
|
*c += 1;
|
||||||
c
|
c
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut open_requests = self.open_requests.write().await;
|
let mut waiting_requests = self.waiting_requests.write().await;
|
||||||
open_requests.insert(*count, chan); // `count` is the request id
|
waiting_requests.insert(*count, waiter); // `count` is the request id
|
||||||
*count
|
*count
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unregister_request(&self, id: u64) {
|
pub async fn unregister_request(&self, id: u64) {
|
||||||
let mut open_requests = self.open_requests.write().await;
|
let mut waiting_requests = self.waiting_requests.write().await;
|
||||||
open_requests.remove(&id);
|
waiting_requests.remove(&id);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn req_count(&self) -> usize {
|
pub async fn req_count(&self) -> usize {
|
||||||
let open_requests = self.open_requests.read().await;
|
let waiting_requests = self.waiting_requests.read().await;
|
||||||
open_requests.len()
|
waiting_requests.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn current_rehide_status(&self) -> Option<bool> {
|
||||||
|
// since all requests that are pending at a given time should have the same
|
||||||
|
// value for rehide_after, it doesn't matter which one we use
|
||||||
|
let waiting_requests = self.waiting_requests.read().await;
|
||||||
|
waiting_requests.iter().next().map(|(_id, w)| w.rehide_after)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
|
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
|
||||||
self.renew_session_if_expired().await?;
|
if let Approval::Approved = response.approval {
|
||||||
|
let mut session = self.session.write().await;
|
||||||
|
session.renew_if_expired().await?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut open_requests = self.open_requests.write().await;
|
let mut waiting_requests = self.waiting_requests.write().await;
|
||||||
let chan = open_requests
|
waiting_requests
|
||||||
.remove(&response.id)
|
.get_mut(&response.id)
|
||||||
.ok_or(SendResponseError::NotFound)
|
.ok_or(SendResponseError::NotFound)?
|
||||||
?;
|
.notify(response.approval)
|
||||||
|
|
||||||
chan.send(response.approval)
|
|
||||||
.map_err(|_e| SendResponseError::Abandoned)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_ban(&self, client: Option<Client>) {
|
pub async fn add_ban(&self, client: Option<Client>) {
|
||||||
@ -223,7 +140,7 @@ impl AppState {
|
|||||||
|
|
||||||
runtime::spawn(async move {
|
runtime::spawn(async move {
|
||||||
sleep(Duration::from_secs(5)).await;
|
sleep(Duration::from_secs(5)).await;
|
||||||
let app = crate::APP.get().unwrap();
|
let app = APP.get().unwrap();
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
let mut bans = state.bans.write().await;
|
let mut bans = state.bans.write().await;
|
||||||
bans.remove(&client);
|
bans.remove(&client);
|
||||||
@ -235,127 +152,55 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
|
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
|
||||||
let mut session = self.session.write().await;
|
let base_creds = match *self.session.read().await {
|
||||||
let LockedCredentials {
|
|
||||||
access_key_id,
|
|
||||||
secret_key_enc,
|
|
||||||
salt,
|
|
||||||
nonce
|
|
||||||
} = match *session {
|
|
||||||
Session::Empty => {return Err(UnlockError::NoCredentials);},
|
Session::Empty => {return Err(UnlockError::NoCredentials);},
|
||||||
Session::Unlocked{..} => {return Err(UnlockError::NotLocked);},
|
Session::Unlocked{..} => {return Err(UnlockError::NotLocked);},
|
||||||
Session::Locked(ref c) => c,
|
Session::Locked(ref locked) => locked.decrypt(passphrase)?,
|
||||||
};
|
|
||||||
|
|
||||||
let mut key_buf = [0; secretbox::KEYBYTES];
|
|
||||||
// pretty sure this only fails if we're out of memory
|
|
||||||
pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), salt).unwrap();
|
|
||||||
let decrypted = secretbox::open(secret_key_enc, nonce, &Key(key_buf))
|
|
||||||
.map_err(|_e| UnlockError::BadPassphrase)?;
|
|
||||||
|
|
||||||
let secret_access_key = String::from_utf8(decrypted).map_err(|_e| UnlockError::InvalidUtf8)?;
|
|
||||||
|
|
||||||
let session_creds = self.new_session(access_key_id, &secret_access_key).await?;
|
|
||||||
*session = Session::Unlocked {
|
|
||||||
base: BaseCredentials {
|
|
||||||
access_key_id: access_key_id.clone(),
|
|
||||||
secret_access_key,
|
|
||||||
},
|
|
||||||
session: session_creds
|
|
||||||
};
|
};
|
||||||
|
// Read lock is dropped here, so this doesn't deadlock
|
||||||
|
self.new_session(base_creds).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// pub async fn serialize_base_creds(&self) -> Result<String, GetCredentialsError> {
|
pub async fn is_unlocked(&self) -> bool {
|
||||||
// let session = self.session.read().await;
|
let session = self.session.read().await;
|
||||||
// match *session {
|
matches!(*session, Session::Unlocked{..})
|
||||||
// Session::Unlocked{ref base, ..} => Ok(serde_json::to_string(base).unwrap()),
|
}
|
||||||
// Session::Locked(_) => Err(GetCredentialsError::Locked),
|
|
||||||
// Session::Empty => Err(GetCredentialsError::Empty),
|
pub async fn serialize_base_creds(&self) -> Result<String, GetCredentialsError> {
|
||||||
// }
|
let app_session = self.session.read().await;
|
||||||
// }
|
let (base, _session) = app_session.try_get()?;
|
||||||
|
Ok(serde_json::to_string(base).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn serialize_session_creds(&self) -> Result<String, GetCredentialsError> {
|
pub async fn serialize_session_creds(&self) -> Result<String, GetCredentialsError> {
|
||||||
let session = self.session.read().await;
|
let app_session = self.session.read().await;
|
||||||
match *session {
|
let (_bsae, session) = app_session.try_get()?;
|
||||||
Session::Unlocked{ref session, ..} => Ok(serde_json::to_string(session).unwrap()),
|
Ok(serde_json::to_string(session).unwrap())
|
||||||
Session::Locked(_) => Err(GetCredentialsError::Locked),
|
}
|
||||||
Session::Empty => Err(GetCredentialsError::Empty),
|
|
||||||
|
async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> {
|
||||||
|
let session = SessionCredentials::from_base(&base).await?;
|
||||||
|
let mut app_session = self.session.write().await;
|
||||||
|
*app_session = Session::Unlocked {base, session};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register_terminal_request(&self) -> Result<(), ()> {
|
||||||
|
let mut req = self.pending_terminal_request.write().await;
|
||||||
|
if *req {
|
||||||
|
// if a request is already pending, we can't register a new one
|
||||||
|
Err(())
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
*req = true;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn new_session(&self, key_id: &str, secret_key: &str) -> Result<SessionCredentials, GetSessionError> {
|
pub async fn unregister_terminal_request(&self) {
|
||||||
let creds = aws_sdk_sts::Credentials::new(
|
let mut req = self.pending_terminal_request.write().await;
|
||||||
key_id,
|
*req = false;
|
||||||
secret_key,
|
|
||||||
None, // token
|
|
||||||
None, // expiration
|
|
||||||
"creddy", // "provider name" apparently
|
|
||||||
);
|
|
||||||
let config = aws_config::from_env()
|
|
||||||
.credentials_provider(creds)
|
|
||||||
.load()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let client = aws_sdk_sts::Client::new(&config);
|
|
||||||
let resp = client.get_session_token()
|
|
||||||
.duration_seconds(43_200)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let aws_session = resp.credentials().ok_or(GetSessionError::EmptyResponse)?;
|
|
||||||
|
|
||||||
let access_key_id = aws_session.access_key_id()
|
|
||||||
.ok_or(GetSessionError::EmptyResponse)?
|
|
||||||
.to_string();
|
|
||||||
let secret_access_key = aws_session.secret_access_key()
|
|
||||||
.ok_or(GetSessionError::EmptyResponse)?
|
|
||||||
.to_string();
|
|
||||||
let token = aws_session.session_token()
|
|
||||||
.ok_or(GetSessionError::EmptyResponse)?
|
|
||||||
.to_string();
|
|
||||||
let expiration = aws_session.expiration()
|
|
||||||
.ok_or(GetSessionError::EmptyResponse)?
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
let session_creds = SessionCredentials {
|
|
||||||
access_key_id,
|
|
||||||
secret_access_key,
|
|
||||||
token,
|
|
||||||
expiration,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
println!("Got new session:\n{}", serde_json::to_string(&session_creds).unwrap());
|
|
||||||
|
|
||||||
Ok(session_creds)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn renew_session_if_expired(&self) -> Result<bool, GetSessionError> {
|
|
||||||
match *self.session.write().await {
|
|
||||||
Session::Unlocked{ref base, ref mut session} => {
|
|
||||||
if !session.is_expired() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let new_session = self.new_session(
|
|
||||||
&base.access_key_id,
|
|
||||||
&base.secret_access_key
|
|
||||||
).await?;
|
|
||||||
*session = new_session;
|
|
||||||
Ok(true)
|
|
||||||
},
|
|
||||||
Session::Locked(_) => Err(GetSessionError::CredentialsLocked),
|
|
||||||
Session::Empty => Err(GetSessionError::CredentialsEmpty),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn serialize_expiration<S>(exp: &AwsDateTime, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where S: Serializer
|
|
||||||
{
|
|
||||||
// this only fails if the d/t is out of range, which it can't be for this format
|
|
||||||
let time_str = exp.fmt(AwsDateTimeFormat::DateTime).unwrap();
|
|
||||||
serializer.serialize_str(&time_str)
|
|
||||||
}
|
|
||||||
|
82
src-tauri/src/terminal.rs
Normal file
82
src-tauri/src/terminal.rs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use crate::app::APP;
|
||||||
|
use crate::errors::*;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> {
|
||||||
|
let app = APP.get().unwrap();
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
|
||||||
|
// register_terminal_request() returns Err if there is another request pending
|
||||||
|
if state.register_terminal_request().await.is_err() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = {
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let mut cmd = Command::new(&config.terminal.exec);
|
||||||
|
cmd.args(&config.terminal.args);
|
||||||
|
cmd
|
||||||
|
};
|
||||||
|
|
||||||
|
// if session is unlocked or empty, wait for credentials from frontend
|
||||||
|
if !state.is_unlocked().await {
|
||||||
|
app.emit_all("launch-terminal-request", ())?;
|
||||||
|
let window = app.get_window("main")
|
||||||
|
.ok_or(LaunchTerminalError::NoMainWindow)?;
|
||||||
|
if !window.is_visible()? {
|
||||||
|
window.unminimize()?;
|
||||||
|
window.show()?;
|
||||||
|
}
|
||||||
|
window.set_focus()?;
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
app.once_global("credentials-event", move |e| {
|
||||||
|
let success = match e.payload() {
|
||||||
|
Some("\"unlocked\"") | Some("\"entered\"") => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
let _ = tx.send(success);
|
||||||
|
});
|
||||||
|
|
||||||
|
if !rx.await.unwrap_or(false) {
|
||||||
|
state.unregister_terminal_request().await;
|
||||||
|
return Ok(()); // request was canceled by user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// more lock-management
|
||||||
|
{
|
||||||
|
let app_session = state.session.read().await;
|
||||||
|
// session should really be unlocked at this point, but if the frontend misbehaves
|
||||||
|
// (i.e. lies about unlocking) we could end up here with a locked session
|
||||||
|
// this will result in an error popup to the user (see main hotkey handler)
|
||||||
|
let (base_creds, session_creds) = app_session.try_get()?;
|
||||||
|
if use_base {
|
||||||
|
cmd.env("AWS_ACCESS_KEY_ID", &base_creds.access_key_id);
|
||||||
|
cmd.env("AWS_SECRET_ACCESS_KEY", &base_creds.secret_access_key);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
cmd.env("AWS_ACCESS_KEY_ID", &session_creds.access_key_id);
|
||||||
|
cmd.env("AWS_SECRET_ACCESS_KEY", &session_creds.secret_access_key);
|
||||||
|
cmd.env("AWS_SESSION_TOKEN", &session_creds.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = match cmd.spawn() {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(e) if std::io::ErrorKind::NotFound == e.kind() => {
|
||||||
|
Err(ExecError::NotFound(cmd.get_program().to_owned()))
|
||||||
|
},
|
||||||
|
Err(e) => Err(ExecError::ExecutionFailed(e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
state.unregister_terminal_request().await;
|
||||||
|
|
||||||
|
res?; // ? auto-conversion is more liberal than .into()
|
||||||
|
Ok(())
|
||||||
|
}
|
@ -8,11 +8,12 @@
|
|||||||
},
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"productName": "creddy",
|
"productName": "creddy",
|
||||||
"version": "0.1.0"
|
"version": "0.3.3"
|
||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": {
|
"allowlist": {
|
||||||
"os": {"all": true}
|
"os": {"all": true},
|
||||||
|
"dialog": {"open": true}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
@ -44,7 +45,11 @@
|
|||||||
"windows": {
|
"windows": {
|
||||||
"certificateThumbprint": null,
|
"certificateThumbprint": null,
|
||||||
"digestAlgorithm": "sha256",
|
"digestAlgorithm": "sha256",
|
||||||
"timestampUrl": ""
|
"timestampUrl": "",
|
||||||
|
"wix": {
|
||||||
|
"fragmentPaths": ["conf/cli.wxs"],
|
||||||
|
"componentRefs": ["CliBinary", "AddToPath"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
|
@ -16,6 +16,25 @@ listen('credentials-request', (tauriEvent) => {
|
|||||||
$appState.pendingRequests.put(tauriEvent.payload);
|
$appState.pendingRequests.put(tauriEvent.payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
listen('launch-terminal-request', async (tauriEvent) => {
|
||||||
|
if ($appState.currentRequest === null) {
|
||||||
|
let status = await invoke('get_session_status');
|
||||||
|
if (status === 'locked') {
|
||||||
|
navigate('Unlock');
|
||||||
|
}
|
||||||
|
else if (status === 'empty') {
|
||||||
|
navigate('EnterCredentials');
|
||||||
|
}
|
||||||
|
// else, session is unlocked, so do nothing
|
||||||
|
// (although we shouldn't even get the event in that case)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
invoke('get_setup_errors')
|
||||||
|
.then(errs => {
|
||||||
|
$appState.setupErrors = errs.map(e => ({msg: e, show: true}));
|
||||||
|
});
|
||||||
|
|
||||||
acceptRequest();
|
acceptRequest();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -9,6 +9,10 @@ export default function() {
|
|||||||
|
|
||||||
resolvers: [],
|
resolvers: [],
|
||||||
|
|
||||||
|
size() {
|
||||||
|
return this.items.length;
|
||||||
|
},
|
||||||
|
|
||||||
put(item) {
|
put(item) {
|
||||||
this.items.push(item);
|
this.items.push(item);
|
||||||
let resolver = this.resolvers.shift();
|
let resolver = this.resolvers.shift();
|
||||||
|
@ -8,6 +8,7 @@ export let appState = writable({
|
|||||||
currentRequest: null,
|
currentRequest: null,
|
||||||
pendingRequests: queue(),
|
pendingRequests: queue(),
|
||||||
credentialStatus: 'locked',
|
credentialStatus: 'locked',
|
||||||
|
setupErrors: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
13
src/ui/KeyCombo.svelte
Normal file
13
src/ui/KeyCombo.svelte
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<script>
|
||||||
|
export let keys;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex gap-x-[0.2em] items-center">
|
||||||
|
{#each keys as key, i}
|
||||||
|
{#if i > 0}
|
||||||
|
<span class="mt-[-0.1em]">+</span>
|
||||||
|
{/if}
|
||||||
|
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">{key}</kbd>
|
||||||
|
{/each}
|
||||||
|
</div>
|
42
src/ui/Spinner.svelte
Normal file
42
src/ui/Spinner.svelte
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<script>
|
||||||
|
export let thickness = 8;
|
||||||
|
let classes = '';
|
||||||
|
export { classes as class };
|
||||||
|
|
||||||
|
const radius = (100 - thickness) / 2;
|
||||||
|
// the px are fake, but we need them to satisfy css calc()
|
||||||
|
const circumference = `${2 * Math.PI * radius}px`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<svg
|
||||||
|
style:--circumference={circumference}
|
||||||
|
class={classes}
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<circle cx="50" cy="50" r={radius} stroke-width={thickness} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
circle {
|
||||||
|
fill: transparent;
|
||||||
|
stroke-dasharray: var(--circumference);
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transform-origin: center;
|
||||||
|
animation: chase 3s infinite,
|
||||||
|
spin 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes chase {
|
||||||
|
0% { stroke-dashoffset: calc(-1 * var(--circumference)); }
|
||||||
|
50% { stroke-dashoffset: calc(-2 * var(--circumference)); }
|
||||||
|
100% { stroke-dashoffset: calc(-3 * var(--circumference)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
50% { transform: rotate(135deg); }
|
||||||
|
100% { transform: rotate(270deg); }
|
||||||
|
}
|
||||||
|
</style>
|
27
src/ui/settings/FileSetting.svelte
Normal file
27
src/ui/settings/FileSetting.svelte
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { open } from '@tauri-apps/api/dialog';
|
||||||
|
import Setting from './Setting.svelte';
|
||||||
|
|
||||||
|
export let title;
|
||||||
|
export let value;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<Setting {title}>
|
||||||
|
<div slot="input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-sm input-bordered grow text-right"
|
||||||
|
bind:value
|
||||||
|
on:change={() => dispatch('update', {value})}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
on:click={async () => value = await open()}
|
||||||
|
>Browse</button>
|
||||||
|
</div>
|
||||||
|
<slot name="description" slot="description"></slot>
|
||||||
|
</Setting>
|
72
src/ui/settings/Keybind.svelte
Normal file
72
src/ui/settings/Keybind.svelte
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import KeyCombo from '../KeyCombo.svelte';
|
||||||
|
|
||||||
|
export let description;
|
||||||
|
export let value;
|
||||||
|
|
||||||
|
const id = Math.random().toString().slice(2);
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
const MODIFIERS = new Set(['Alt', 'AltGraph', 'Control', 'Fn', 'FnLock', 'Meta', 'Shift', 'Super', ]);
|
||||||
|
|
||||||
|
|
||||||
|
let listening = false;
|
||||||
|
let keysPressed = [];
|
||||||
|
|
||||||
|
function addModifiers(event) {
|
||||||
|
// add modifier key if it isn't already present
|
||||||
|
if (MODIFIERS.has(event.key) && keysPressed.indexOf(event.key) === -1) {
|
||||||
|
keysPressed.push(event.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMainKey(event) {
|
||||||
|
if (!MODIFIERS.has(event.key)) {
|
||||||
|
keysPressed.push(event.key);
|
||||||
|
|
||||||
|
value.keys = keysPressed.join('+');
|
||||||
|
dispatch('update', {value});
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen() {
|
||||||
|
// don't re-listen if we already are
|
||||||
|
if (listening) return;
|
||||||
|
|
||||||
|
listening = true;
|
||||||
|
window.addEventListener('keydown', addModifiers);
|
||||||
|
window.addEventListener('keyup', addMainKey);
|
||||||
|
// setTimeout avoids reacting to the click event that we are currently processing
|
||||||
|
setTimeout(() => window.addEventListener('click', unlisten), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlisten() {
|
||||||
|
listening = false;
|
||||||
|
keysPressed = [];
|
||||||
|
window.removeEventListener('keydown', addModifiers);
|
||||||
|
window.removeEventListener('keyup', addMainKey);
|
||||||
|
window.removeEventListener('click', unlisten);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary"
|
||||||
|
bind:checked={value.enabled}
|
||||||
|
on:change={() => dispatch('update', {value})}
|
||||||
|
>
|
||||||
|
<label for={id} class="cursor-pointer ml-4 text-lg">{description}</label>
|
||||||
|
|
||||||
|
<button class="h-12 p-2 rounded border border-neutral cursor-pointer text-center" on:click={listen}>
|
||||||
|
{#if listening}
|
||||||
|
Click to cancel
|
||||||
|
{:else}
|
||||||
|
<KeyCombo keys={value.keys.split('+')} />
|
||||||
|
{/if}
|
||||||
|
</button>
|
@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
export let title;
|
export let title;
|
||||||
export let value;
|
export let value;
|
||||||
|
|
||||||
export let unit = '';
|
export let unit = '';
|
||||||
export let min = null;
|
export let min = null;
|
||||||
export let max = null;
|
export let max = null;
|
||||||
|
@ -6,14 +6,17 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div>
|
||||||
<div class="flex justify-between">
|
<div class="flex flex-wrap justify-between gap-y-4">
|
||||||
<h3 class="text-lg font-bold">{title}</h3>
|
<h3 class="text-lg font-bold shrink-0">{title}</h3>
|
||||||
|
{#if $$slots.input}
|
||||||
<slot name="input"></slot>
|
<slot name="input"></slot>
|
||||||
</div>
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if $$slots.description}
|
{#if $$slots.description}
|
||||||
<p class="mt-3">
|
<p class="mt-3">
|
||||||
<slot name="description"></slot>
|
<slot name="description"></slot>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
14
src/ui/settings/SettingsGroup.svelte
Normal file
14
src/ui/settings/SettingsGroup.svelte
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script>
|
||||||
|
export let name;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="divider mt-0 mb-8">
|
||||||
|
<h2 class="text-xl font-bold">{name}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-12">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
22
src/ui/settings/TextSetting.svelte
Normal file
22
src/ui/settings/TextSetting.svelte
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import Setting from './Setting.svelte';
|
||||||
|
|
||||||
|
export let title;
|
||||||
|
export let value;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<Setting {title}>
|
||||||
|
<div slot="input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-sm input-bordered grow text-right"
|
||||||
|
bind:value
|
||||||
|
on:change={() => dispatch('update', {value})}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<slot name="description" slot="description"></slot>
|
||||||
|
</Setting>
|
@ -1,3 +1,5 @@
|
|||||||
export { default as Setting } from './Setting.svelte';
|
export { default as Setting } from './Setting.svelte';
|
||||||
export { default as ToggleSetting } from './ToggleSetting.svelte';
|
export { default as ToggleSetting } from './ToggleSetting.svelte';
|
||||||
export { default as NumericSetting } from './NumericSetting.svelte';
|
export { default as NumericSetting } from './NumericSetting.svelte';
|
||||||
|
export { default as FileSetting } from './FileSetting.svelte';
|
||||||
|
export { default as TextSetting } from './TextSetting.svelte';
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
import { appState, completeRequest } from '../lib/state.js';
|
import { appState, completeRequest } from '../lib/state.js';
|
||||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||||
import Link from '../ui/Link.svelte';
|
import Link from '../ui/Link.svelte';
|
||||||
|
import KeyCombo from '../ui/KeyCombo.svelte';
|
||||||
|
|
||||||
|
|
||||||
// Send response to backend, display error if applicable
|
// Send response to backend, display error if applicable
|
||||||
@ -68,7 +69,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<!-- Don't render at all if we're just going to immediately proceed to the next screen -->
|
<!-- Don't render at all if we're just going to immediately proceed to the next screen -->
|
||||||
{#if !$appState.currentRequest.approval}
|
{#if error || !$appState.currentRequest.approval}
|
||||||
<div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center">
|
<div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center">
|
||||||
{#if error}
|
{#if error}
|
||||||
<ErrorAlert bind:this={alert}>
|
<ErrorAlert bind:this={alert}>
|
||||||
@ -80,6 +81,18 @@
|
|||||||
</ErrorAlert>
|
</ErrorAlert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if $appState.currentRequest.base}
|
||||||
|
<div class="alert alert-warning shadow-lg">
|
||||||
|
<div>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
|
<span>
|
||||||
|
WARNING: This application is requesting your base (long-lived) AWS credentials.
|
||||||
|
These credentials are less secure than session credentials, since they don't expire automatically.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-1 mb-4">
|
<div class="space-y-1 mb-4">
|
||||||
<h2 class="text-xl font-bold">{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.</h2>
|
<h2 class="text-xl font-bold">{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.</h2>
|
||||||
|
|
||||||
@ -96,17 +109,15 @@
|
|||||||
<div class="w-full flex justify-between">
|
<div class="w-full flex justify-between">
|
||||||
<Link target={deny} hotkey="Escape">
|
<Link target={deny} hotkey="Escape">
|
||||||
<button class="btn btn-error justify-self-start">
|
<button class="btn btn-error justify-self-start">
|
||||||
Deny
|
<span class="mr-2">Deny</span>
|
||||||
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Esc</kbd>
|
<KeyCombo keys={['Esc']} />
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link target={approve} hotkey="Enter" shift="{true}">
|
<Link target={approve} hotkey="Enter" shift="{true}">
|
||||||
<button class="btn btn-success justify-self-end">
|
<button class="btn btn-success justify-self-end">
|
||||||
Approve
|
<span class="mr-2">Approve</span>
|
||||||
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Shift</kbd>
|
<KeyCombo keys={['Shift', 'Enter']} />
|
||||||
<span class="mx-0.5">+</span>
|
|
||||||
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">Enter</kbd>
|
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
import { navigate } from '../lib/routing.js';
|
import { navigate } from '../lib/routing.js';
|
||||||
import Link from '../ui/Link.svelte';
|
import Link from '../ui/Link.svelte';
|
||||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||||
|
import Spinner from '../ui/Spinner.svelte';
|
||||||
|
|
||||||
|
|
||||||
let errorMsg = null;
|
let errorMsg = null;
|
||||||
@ -19,6 +20,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let saving = false;
|
||||||
async function save() {
|
async function save() {
|
||||||
if (passphrase !== confirmPassphrase) {
|
if (passphrase !== confirmPassphrase) {
|
||||||
alert.shake();
|
alert.shake();
|
||||||
@ -27,7 +29,9 @@
|
|||||||
|
|
||||||
let credentials = {AccessKeyId, SecretAccessKey};
|
let credentials = {AccessKeyId, SecretAccessKey};
|
||||||
try {
|
try {
|
||||||
|
saving = true;
|
||||||
await invoke('save_credentials', {credentials, passphrase});
|
await invoke('save_credentials', {credentials, passphrase});
|
||||||
|
emit('credentials-event', 'entered');
|
||||||
if ($appState.currentRequest) {
|
if ($appState.currentRequest) {
|
||||||
navigate('Approve');
|
navigate('Approve');
|
||||||
}
|
}
|
||||||
@ -36,19 +40,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
if (e.code === "GetSession") {
|
window.error = e;
|
||||||
let root = getRootCause(e);
|
const root = getRootCause(e);
|
||||||
|
if (e.code === 'GetSession' && root.code) {
|
||||||
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
|
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
errorMsg = e.msg;
|
errorMsg = e.msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if the alert already existed, shake it
|
||||||
if (alert) {
|
if (alert) {
|
||||||
alert.shake();
|
alert.shake();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
emit('credentials-event', 'enter-canceled');
|
||||||
|
navigate('Home');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@ -65,8 +78,14 @@
|
|||||||
<input type="password" placeholder="Passphrase" bind:value="{passphrase}" class="input input-bordered" />
|
<input type="password" placeholder="Passphrase" bind:value="{passphrase}" class="input input-bordered" />
|
||||||
<input type="password" placeholder="Re-enter passphrase" bind:value={confirmPassphrase} class="input input-bordered" on:change={confirm} />
|
<input type="password" placeholder="Re-enter passphrase" bind:value={confirmPassphrase} class="input input-bordered" on:change={confirm} />
|
||||||
|
|
||||||
<input type="submit" class="btn btn-primary" />
|
<button type="submit" class="btn btn-primary">
|
||||||
<Link target="Home" hotkey="Escape">
|
{#if saving }
|
||||||
|
<Spinner class="w-5 h-5" thickness="12"/>
|
||||||
|
{:else}
|
||||||
|
Submit
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<Link target={cancel} hotkey="Escape">
|
||||||
<button class="btn btn-sm btn-outline w-full">Cancel</button>
|
<button class="btn btn-sm btn-outline w-full">Cancel</button>
|
||||||
</Link>
|
</Link>
|
||||||
</form>
|
</form>
|
||||||
|
@ -10,13 +10,11 @@
|
|||||||
|
|
||||||
import vaultDoorSvg from '../assets/vault_door.svg?raw';
|
import vaultDoorSvg from '../assets/vault_door.svg?raw';
|
||||||
|
|
||||||
|
let launchBase = false;
|
||||||
// onMount(async () => {
|
function launchTerminal() {
|
||||||
// // will block until a request comes in
|
invoke('launch_terminal', {base: launchBase});
|
||||||
// let req = await $appState.pendingRequests.get();
|
launchBase = false;
|
||||||
// $appState.currentRequest = req;
|
}
|
||||||
// navigate('Approve');
|
|
||||||
// });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@ -25,25 +23,45 @@
|
|||||||
</Nav>
|
</Nav>
|
||||||
|
|
||||||
<div class="flex flex-col h-screen items-center justify-center p-4 space-y-4">
|
<div class="flex flex-col h-screen items-center justify-center p-4 space-y-4">
|
||||||
|
<div class="flex flex-col items-center space-y-4">
|
||||||
|
{@html vaultDoorSvg}
|
||||||
{#await invoke('get_session_status') then status}
|
{#await invoke('get_session_status') then status}
|
||||||
{#if status === 'locked'}
|
{#if status === 'locked'}
|
||||||
|
|
||||||
{@html vaultDoorSvg}
|
|
||||||
<h2 class="text-2xl font-bold">Creddy is locked</h2>
|
<h2 class="text-2xl font-bold">Creddy is locked</h2>
|
||||||
<Link target="Unlock" hotkey="Enter" class="w-64">
|
<Link target="Unlock" hotkey="Enter" class="w-64">
|
||||||
<button class="btn btn-primary w-full">Unlock</button>
|
<button class="btn btn-primary w-full">Unlock</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{:else if status === 'unlocked'}
|
{:else if status === 'unlocked'}
|
||||||
{@html vaultDoorSvg}
|
|
||||||
<h2 class="text-2xl font-bold">Waiting for requests</h2>
|
<h2 class="text-2xl font-bold">Waiting for requests</h2>
|
||||||
|
<button class="btn btn-primary w-full" on:click={launchTerminal}>
|
||||||
|
Launch Terminal
|
||||||
|
</button>
|
||||||
|
<label class="label cursor-pointer flex items-center space-x-2">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}>
|
||||||
|
<span class="label-text">Launch with base credentials</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{:else if status === 'empty'}
|
{:else if status === 'empty'}
|
||||||
{@html vaultDoorSvg}
|
|
||||||
<h2 class="text-2xl font-bold">No credentials found</h2>
|
<h2 class="text-2xl font-bold">No credentials found</h2>
|
||||||
<Link target="EnterCredentials" hotkey="Enter" class="w-64">
|
<Link target="EnterCredentials" hotkey="Enter" class="w-64">
|
||||||
<button class="btn btn-primary w-full">Enter Credentials</button>
|
<button class="btn btn-primary w-full">Enter Credentials</button>
|
||||||
</Link>
|
</Link>
|
||||||
{/if}
|
{/if}
|
||||||
{/await}
|
{/await}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if $appState.setupErrors.some(e => e.show)}
|
||||||
|
<div class="toast">
|
||||||
|
{#each $appState.setupErrors as error}
|
||||||
|
{#if error.show}
|
||||||
|
<div class="alert alert-error shadow-lg">
|
||||||
|
{error.msg}
|
||||||
|
<button class="btn btn-sm btn-alert-error" on:click={() => error.show = false}>Ok</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
@ -6,7 +6,9 @@
|
|||||||
import Nav from '../ui/Nav.svelte';
|
import Nav from '../ui/Nav.svelte';
|
||||||
import Link from '../ui/Link.svelte';
|
import Link from '../ui/Link.svelte';
|
||||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||||
import { Setting, ToggleSetting, NumericSetting } from '../ui/settings';
|
import SettingsGroup from '../ui/settings/SettingsGroup.svelte';
|
||||||
|
import Keybind from '../ui/settings/Keybind.svelte';
|
||||||
|
import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting } from '../ui/settings';
|
||||||
|
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { backInOut } from 'svelte/easing';
|
import { backInOut } from 'svelte/easing';
|
||||||
@ -14,6 +16,7 @@
|
|||||||
|
|
||||||
let error = null;
|
let error = null;
|
||||||
async function save() {
|
async function save() {
|
||||||
|
console.log('updating config');
|
||||||
try {
|
try {
|
||||||
await invoke('save_config', {config: $appState.config});
|
await invoke('save_config', {config: $appState.config});
|
||||||
}
|
}
|
||||||
@ -23,19 +26,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let osType = '';
|
let osType = null;
|
||||||
type().then(t => osType = t);
|
type().then(t => osType = t);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<Nav>
|
<Nav>
|
||||||
<h2 slot="title" class="text-2xl font-bold">Settings</h2>
|
<h1 slot="title" class="text-2xl font-bold">Settings</h1>
|
||||||
</Nav>
|
</Nav>
|
||||||
|
|
||||||
{#await invoke('get_config') then config}
|
{#await invoke('get_config') then config}
|
||||||
<div class="max-w-md mx-auto mt-1.5 p-4">
|
<div class="max-w-lg mx-auto mt-1.5 p-4 space-y-16">
|
||||||
<!-- <h2 class="text-2xl font-bold text-center">Settings</h2> -->
|
<SettingsGroup name="General">
|
||||||
|
|
||||||
<ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}>
|
<ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}>
|
||||||
<svelte:fragment slot="description">
|
<svelte:fragment slot="description">
|
||||||
Start Creddy when you log in to your computer.
|
Start Creddy when you log in to your computer.
|
||||||
@ -76,6 +78,29 @@
|
|||||||
Update or re-enter your encrypted credentials.
|
Update or re-enter your encrypted credentials.
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
</Setting>
|
</Setting>
|
||||||
|
|
||||||
|
<FileSetting
|
||||||
|
title="Terminal emulator"
|
||||||
|
bind:value={$appState.config.terminal.exec}
|
||||||
|
on:update={save}
|
||||||
|
>
|
||||||
|
<svelte:fragment slot="description">
|
||||||
|
Choose your preferred terminal emulator (e.g. <code>gnome-terminal</code> or <code>wt.exe</code>.) May be an absolute path or an executable discoverable on <code>$PATH</code>.
|
||||||
|
</svelte:fragment>
|
||||||
|
</FileSetting>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup name="Hotkeys">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p>Click on a keybinding to modify it. Use the checkbox to enable or disable a keybinding entirely.</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[auto_1fr_auto] gap-y-3 items-center">
|
||||||
|
<Keybind description="Show Creddy" value={$appState.config.hotkeys.show_window} on:update={save} />
|
||||||
|
<Keybind description="Launch terminal" value={$appState.config.hotkeys.launch_terminal} on:update={save} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/await}
|
{/await}
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { invoke } from '@tauri-apps/api/tauri';
|
import { invoke } from '@tauri-apps/api/tauri';
|
||||||
|
import { emit } from '@tauri-apps/api/event';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
import { appState } from '../lib/state.js';
|
import { appState } from '../lib/state.js';
|
||||||
@ -7,12 +8,14 @@
|
|||||||
import { getRootCause } from '../lib/errors.js';
|
import { getRootCause } from '../lib/errors.js';
|
||||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||||
import Link from '../ui/Link.svelte';
|
import Link from '../ui/Link.svelte';
|
||||||
|
import Spinner from '../ui/Spinner.svelte';
|
||||||
|
|
||||||
|
|
||||||
let errorMsg = null;
|
let errorMsg = null;
|
||||||
let alert;
|
let alert;
|
||||||
let passphrase = '';
|
let passphrase = '';
|
||||||
let loadTime = 0;
|
let loadTime = 0;
|
||||||
|
let saving = false;
|
||||||
async function unlock() {
|
async function unlock() {
|
||||||
// The hotkey for navigating here from homepage is Enter, which also
|
// The hotkey for navigating here from homepage is Enter, which also
|
||||||
// happens to trigger the form submit event
|
// happens to trigger the form submit event
|
||||||
@ -21,8 +24,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
saving = true;
|
||||||
let r = await invoke('unlock', {passphrase});
|
let r = await invoke('unlock', {passphrase});
|
||||||
$appState.credentialStatus = 'unlocked';
|
$appState.credentialStatus = 'unlocked';
|
||||||
|
emit('credentials-event', 'unlocked');
|
||||||
if ($appState.currentRequest) {
|
if ($appState.currentRequest) {
|
||||||
navigate('Approve');
|
navigate('Approve');
|
||||||
}
|
}
|
||||||
@ -31,21 +36,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
window.error = e;
|
const root = getRootCause(e);
|
||||||
if (e.code === 'GetSession') {
|
if (e.code === 'GetSession' && root.code) {
|
||||||
let root = getRootCause(e);
|
|
||||||
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
|
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
errorMsg = e.msg;
|
errorMsg = e.msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if the alert already existed, shake it
|
||||||
if (alert) {
|
if (alert) {
|
||||||
alert.shake();
|
alert.shake();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
emit('credentials-event', 'unlock-canceled');
|
||||||
|
navigate('Home');
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadTime = Date.now();
|
loadTime = Date.now();
|
||||||
})
|
})
|
||||||
@ -62,8 +74,15 @@
|
|||||||
<!-- svelte-ignore a11y-autofocus -->
|
<!-- svelte-ignore a11y-autofocus -->
|
||||||
<input autofocus name="password" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" class="input input-bordered" />
|
<input autofocus name="password" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" class="input input-bordered" />
|
||||||
|
|
||||||
<input type="submit" class="btn btn-primary" />
|
<button type="submit" class="btn btn-primary">
|
||||||
<Link target="Home" hotkey="Escape">
|
{#if saving}
|
||||||
<button class="btn btn-outline btn-sm w-full">Cancel</button>
|
<Spinner class="w-5 h-5" thickness="12"/>
|
||||||
|
{:else}
|
||||||
|
Submit
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link target={cancel} hotkey="Escape">
|
||||||
|
<button class="btn btn-sm btn-outline w-full">Cancel</button>
|
||||||
</Link>
|
</Link>
|
||||||
</form>
|
</form>
|
||||||
|
Reference in New Issue
Block a user