33 Commits

Author SHA1 Message Date
47a3e1cfef start work on invoking shortcuts from CLI 2023-09-18 20:13:56 -07:00
1047818fdc basic implementation of named-pipe server 2023-09-18 20:13:29 -07:00
3d093a3a45 show version in cli 2023-09-14 15:22:38 -07:00
992d2a4d06 show non-fatal setup errors on home screen instead of in popup 2023-09-14 15:13:19 -07:00
12f0f187a6 update cargo and npm 2023-09-14 12:49:45 -07:00
997e8b419f handle setup errors more gracefully 2023-09-13 11:06:40 -07:00
1d9132de3b make hotkey configuration more timing tolerant 2023-09-12 21:46:25 -07:00
e1c2618dc8 bump version 2023-09-12 15:31:03 -07:00
a7df7adc8e Ignore keyup events for modifier keys 2023-09-12 15:27:15 -07:00
03d164c9d3 Inherit rehide flag from existing request if present 2023-09-12 14:10:57 -07:00
f522674a1c don't remove request from state until after re-hiding window 2023-09-12 11:47:33 -07:00
51fcccafa2 fix os type calculation and bump version 2023-09-11 16:18:05 -07:00
e3913ab4c9 add todo list 2023-09-11 16:11:06 -07:00
c16f21bba3 Merge branch 'terminal' 2023-09-11 16:10:58 -07:00
61d9acc7c6 request unlock/credentials when terminal is launched from locked/empty state 2023-09-11 16:00:58 -07:00
8d7b01629d make keybinds configurable 2023-09-10 14:04:09 -07:00
5685948608 add hotkeys to show window and launch terminal 2023-09-09 07:29:57 -07:00
c98a065587 make terminal emulator configurable 2023-09-09 06:30:19 -07:00
e46c3d2b4d tweak home screen 2023-09-05 06:12:26 -07:00
fa228acc3a use svg animation for spinner 2023-08-06 21:25:24 -07:00
e7e0f9d33e very basic launch button 2023-08-03 22:08:24 -07:00
a51b20add7 combine ExecError with LaunchError and use Session::try_get() instead of matching 2023-08-03 21:57:55 -07:00
890f715388 usable backend for terminal launch 2023-08-03 16:35:15 -07:00
89bc74e644 start working on terminal launcher 2023-08-02 19:57:37 -07:00
60c24e3ee4 don't autohide on first launch 2023-07-11 16:13:20 -07:00
486001b584 improve display of GetSessionError 2023-07-11 14:34:54 -07:00
52c949e396 v0.2.3 2023-07-11 10:35:56 -07:00
d7c5c2f37b update dependencies 2023-07-11 09:52:13 -07:00
ae5b8f31db remove spinner when unlock fails 2023-07-11 09:50:35 -07:00
c260e37e78 cryptography notes 2023-05-19 10:04:48 -07:00
7501253970 add separate binary for Windows CLI 2023-05-15 13:09:26 -07:00
5b9c711008 fix subprocess exec for unix 2023-05-09 09:47:11 -07:00
ddd1005067 switch crypto implementation and add spinner 2023-05-08 22:14:35 -07:00
39 changed files with 2858 additions and 1511 deletions

9
doc/cryptography.md Normal file
View 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.

19
doc/todo.md Normal file
View File

@ -0,0 +1,19 @@
## 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)
* Use atomic types for primitive state values instead of RwLock'd types

664
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "creddy",
"version": "0.2.0",
"version": "0.3.3",
"scripts": {
"dev": "vite",
"build": "vite build",

1551
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,22 @@
[package]
name = "app"
version = "0.2.0"
description = "A Tauri App"
authors = ["you"]
name = "creddy"
version = "0.3.3"
description = "A friendly AWS credentials manager"
authors = ["Joseph Montanaro"]
license = ""
repository = ""
default-run = "app"
default-run = "creddy"
edition = "2021"
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
[build-dependencies]
@ -17,12 +25,11 @@ tauri-build = { version = "1.0.4", features = [] }
[dependencies]
serde_json = "1.0"
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" }
sodiumoxide = "0.2.7"
tokio = { version = ">=1.19", features = ["full"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] }
netstat2 = "0.9.1"
sysinfo = "0.26.8"
aws-types = "0.52.0"
aws-sdk-sts = "0.22.0"
@ -36,6 +43,10 @@ auto-launch = "0.4.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"
windows = { version = "0.51.1", features = ["Win32_Foundation", "Win32_System_Pipes"] }
[features]
# by default Tauri runs in production mode

22
src-tauri/conf/cli.wxs Normal file
View 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>

View File

@ -42,6 +42,8 @@ pub fn run() -> tauri::Result<()> {
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!())?
@ -74,19 +76,41 @@ pub async fn connect_db() -> Result<SqlitePool, SetupError> {
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 conf = AppConfig::load(&pool).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?;
Server::start(app.handle())?;
config::set_auto_launch(conf.start_on_login)?;
if !conf.start_minimized {
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);
let state = AppState::new(conf, session, pool, setup_errors);
app.manage(state);
Ok(())
}

View File

@ -0,0 +1,46 @@
// 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(("get", m)) => cli::get(m),
Some(("exec", m)) => cli::exec(m),
_ => unreachable!(),
};
if let Err(e) = res {
eprintln!("Error: {e}");
process::exit(1);
}
}
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(())
}

View File

@ -1,6 +1,6 @@
use std::ffi::OsString;
use std::process::Command as ChildCommand;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::time::Duration;
use clap::{
Command,
@ -8,28 +8,37 @@ use clap::{
ArgMatches,
ArgAction
};
use tokio::{
net::TcpStream,
io::{AsyncReadExt, AsyncWriteExt},
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::credentials::Credentials;
use crate::errors::*;
use crate::server::{Request, Response};
#[cfg(unix)]
use {
std::os::unix::process::CommandExt,
std::path::Path,
tokio::net::UnixStream,
};
use crate::app;
use crate::config::AppConfig;
use crate::credentials::{BaseCredentials, SessionCredentials};
use crate::errors::*;
#[cfg(windows)]
use {
tokio::net::windows::named_pipe::{NamedPipeClient, ClientOptions},
windows::Win32::Foundation::ERROR_PIPE_BUSY,
};
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")
Command::new("get")
.about("Request AWS credentials from Creddy and output to stdout")
.arg(
Arg::new("base")
.short('b')
@ -57,10 +66,13 @@ pub fn parser() -> Command<'static> {
}
pub fn show(args: &ArgMatches) -> Result<(), CliError> {
pub fn get(args: &ArgMatches) -> Result<(), CliError> {
let base = args.get_one("base").unwrap_or(&false);
let creds = get_credentials(*base)?;
println!("{creds}");
let output = match get_credentials(*base)? {
Credentials::Base(creds) => serde_json::to_string(&creds).unwrap(),
Credentials::Session(creds) => serde_json::to_string(&creds).unwrap(),
};
println!("{output}");
Ok(())
}
@ -74,27 +86,42 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
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);
match get_credentials(base)? {
Credentials::Base(creds) => {
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
},
Credentials::Session(creds) => {
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.session_token);
}
}
#[cfg(unix)]
cmd.exec().map_err(|e| ExecError::ExecutionFailed(e))?;
{
// 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 = cmd.spawn()
.map_err(|e| ExecError::ExecutionFailed(e))?;
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));
@ -103,40 +130,44 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
#[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 {"/"};
async fn get_credentials(base: bool) -> Result<Credentials, RequestError> {
let req = Request::GetAwsCredentials { base };
let mut data = serde_json::to_string(&req).unwrap();
// server expects newline marking end of request
data.push('\n');
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?;
let mut stream = connect().await?;
stream.write_all(&data.as_bytes()).await?;
// some day we'll have a proper HTTP parser
let mut buf = vec![0; 8192];
let mut buf = Vec::with_capacity(1024);
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 res: Result<Response, ServerError> = serde_json::from_slice(&buf)?;
match res {
Ok(Response::Aws(creds)) => Ok(creds),
// Eventually we will want this
// Ok(r) => Err(RequestError::Unexpected(r)),
Err(e) => Err(RequestError::Server(e)),
}
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)
}
#[cfg(windows)]
async fn connect() -> Result<NamedPipeClient, std::io::Error> {
// apparently attempting to connect can fail if there's already a client connected
loop {
match ClientOptions::new().open(r"\\.\pipe\creddy-requests") {
Ok(stream) => return Ok(stream),
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => (),
Err(e) => return Err(e),
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
#[cfg(unix)]
async fn connect() -> Result<UnixStream, std::io::Error> {
let path = Path::from("/tmp/creddy-requests");
std::fs::remove_file(path)?;
UnixStream::connect(path)
}

View File

@ -1,76 +1,122 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
use tauri::Manager;
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
use serde::{Serialize, Deserialize};
use std::os::windows::io::AsRawHandle;
use crate::{
app::APP,
errors::*,
config::AppConfig,
state::AppState,
#[cfg(windows)]
use {
tokio::net::windows::named_pipe::NamedPipeServer,
windows::Win32::{
Foundation::HANDLE,
System::Pipes::GetNamedPipeClientProcessId,
},
};
#[cfg(unix)]
use tokio::net::UnixStream;
use crate::errors::*;
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
pub struct Client {
pub pid: u32,
pub exe: PathBuf,
pub exe: Option<PathBuf>,
}
async fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
let state = APP.get().unwrap().state::<AppState>();
let AppConfig {
listen_addr: app_listen_addr,
listen_port: app_listen_port,
..
} = *state.config.read().await;
let sockets_iter = netstat2::iterate_sockets_info(
AddressFamilyFlags::IPV4,
ProtocolFlags::TCP
)?;
for item in sockets_iter {
let sock_info = item?;
let proto_info = match sock_info.protocol_socket_info {
ProtocolSocketInfo::Tcp(tcp_info) => tcp_info,
ProtocolSocketInfo::Udp(_) => {continue;}
};
if proto_info.local_port == local_port
&& proto_info.remote_port == app_listen_port
&& proto_info.local_addr == app_listen_addr
&& proto_info.remote_addr == app_listen_addr
{
return Ok(sock_info.associated_pids)
}
}
Ok(vec![])
#[cfg(unix)]
pub fn get_client_parent(stream: &UnixStream) -> Result<Client, ClientInfoError> {
let pid = stream.peer_cred()?;
get_process_parent_info(pid)?
}
#[cfg(windows)]
pub fn get_client_parent(stream: &NamedPipeServer) -> Result<Client, ClientInfoError> {
let raw_handle = stream.as_raw_handle();
let mut pid = 0u32;
let handle = HANDLE(raw_handle as _);
unsafe { GetNamedPipeClientProcessId(handle, &mut pid as *mut u32)? };
get_process_parent_info(pid)
}
fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> {
let sys_pid = Pid::from_u32(pid);
let mut sys = System::new();
sys.refresh_process(sys_pid);
let proc = sys.process(sys_pid)
.ok_or(ClientInfoError::ProcessNotFound)?;
let parent_pid_sys = proc.parent()
.ok_or(ClientInfoError::ParentPidNotFound)?;
sys.refresh_process(parent_pid_sys);
let parent = sys.process(parent_pid_sys)
.ok_or(ClientInfoError::ParentProcessNotFound)?;
let exe = match parent.exe() {
p if p == Path::new("") => None,
p => Some(PathBuf::from(p)),
};
Ok(Client { pid: parent_pid_sys.as_u32(), exe })
}
// async fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
// let state = APP.get().unwrap().state::<AppState>();
// let AppConfig {
// listen_addr: app_listen_addr,
// listen_port: app_listen_port,
// ..
// } = *state.config.read().await;
// let sockets_iter = netstat2::iterate_sockets_info(
// AddressFamilyFlags::IPV4,
// ProtocolFlags::TCP
// )?;
// for item in sockets_iter {
// let sock_info = item?;
// let proto_info = match sock_info.protocol_socket_info {
// ProtocolSocketInfo::Tcp(tcp_info) => tcp_info,
// ProtocolSocketInfo::Udp(_) => {continue;}
// };
// if proto_info.local_port == local_port
// && proto_info.remote_port == app_listen_port
// && proto_info.local_addr == app_listen_addr
// && proto_info.remote_addr == app_listen_addr
// {
// return Ok(sock_info.associated_pids)
// }
// }
// Ok(vec![])
// }
// Theoretically, on some systems, multiple processes can share a socket
pub async fn get_clients(local_port: u16) -> Result<Vec<Option<Client>>, ClientInfoError> {
let mut clients = Vec::new();
let mut sys = System::new();
for p in get_associated_pids(local_port).await? {
let pid = Pid::from_u32(p);
sys.refresh_process(pid);
let proc = sys.process(pid)
.ok_or(ClientInfoError::ProcessNotFound)?;
// pub async fn get_clients(local_port: u16) -> Result<Vec<Option<Client>>, ClientInfoError> {
// let mut clients = Vec::new();
// let mut sys = System::new();
// for p in get_associated_pids(local_port).await? {
// let pid = Pid::from_u32(p);
// sys.refresh_process(pid);
// let proc = sys.process(pid)
// .ok_or(ClientInfoError::ProcessNotFound)?;
let client = Client {
pid: p,
exe: proc.exe().to_path_buf(),
};
clients.push(Some(client));
}
// let client = Client {
// pid: p,
// exe: proc.exe().to_path_buf(),
// };
// clients.push(Some(client));
// }
if clients.is_empty() {
clients.push(None);
}
// if clients.is_empty() {
// clients.push(None);
// }
Ok(clients)
}
// Ok(clients)
// }

View File

@ -1,37 +1,67 @@
use std::net::Ipv4Addr;
use std::path::PathBuf;
use auto_launch::AutoLaunchBuilder;
use is_terminal::IsTerminal;
use serde::{Serialize, Deserialize};
use sqlx::SqlitePool;
use tauri::{
Manager,
GlobalShortcutManager,
async_runtime as rt,
};
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)]
pub struct AppConfig {
#[serde(default = "default_listen_addr")]
pub listen_addr: Ipv4Addr,
#[serde(default = "default_listen_port")]
pub listen_port: u16,
#[serde(default = "default_rehide_ms")]
pub rehide_ms: u64,
#[serde(default = "default_start_minimized")]
pub start_minimized: bool,
#[serde(default = "default_start_on_login")]
pub start_on_login: bool,
#[serde(default = "default_term_config")]
pub terminal: TermConfig,
#[serde(default = "default_hotkey_config")]
pub hotkeys: HotkeysConfig,
}
impl Default for AppConfig {
fn default() -> Self {
AppConfig {
listen_addr: default_listen_addr(),
listen_port: default_listen_port(),
rehide_ms: default_rehide_ms(),
start_minimized: default_start_minimized(),
start_on_login: default_start_on_login(),
terminal: default_term_config(),
hotkeys: default_hotkey_config(),
}
}
}
@ -107,16 +137,90 @@ pub fn get_or_create_db_path() -> Result<PathBuf, DataDirError> {
}
fn default_listen_port() -> u16 {
if cfg!(debug_assertions) {
12_345
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 }
}
else {
19_923
#[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_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST }
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_rehide_ms() -> u64 { 1000 }
// start minimized and on login only in production mode
fn default_start_minimized() -> bool { !cfg!(debug_assertions) }

View File

@ -1,7 +1,25 @@
use std::fmt::{self, Formatter};
use std::time::{SystemTime, UNIX_EPOCH};
use aws_smithy_types::date_time::{DateTime, Format};
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,
@ -10,12 +28,7 @@ use serde::{
};
use serde::de::{self, Visitor};
use sqlx::SqlitePool;
use sodiumoxide::crypto::{
pwhash,
pwhash::Salt,
secretbox,
secretbox::{Nonce, Key}
};
use crate::errors::*;
@ -40,18 +53,17 @@ impl Session {
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
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: Salt(salt_buf),
nonce: Nonce(nonce_buf),
salt,
nonce,
};
Ok(Session::Locked(creds))
}
@ -69,6 +81,16 @@ impl Session {
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))
}
}
}
@ -76,8 +98,8 @@ impl Session {
pub struct LockedCredentials {
pub access_key_id: String,
pub secret_key_enc: Vec<u8>,
pub salt: Salt,
pub nonce: Nonce,
pub salt: [u8; 32],
pub nonce: XNonce,
}
impl LockedCredentials {
@ -88,8 +110,8 @@ impl LockedCredentials {
)
.bind(&self.access_key_id)
.bind(&self.secret_key_enc)
.bind(&self.salt.0[0..])
.bind(&self.nonce.0[0..])
.bind(&self.salt[..])
.bind(&self.nonce[..])
.execute(pool)
.await?;
@ -97,11 +119,10 @@ impl LockedCredentials {
}
pub fn decrypt(&self, passphrase: &str) -> Result<BaseCredentials, UnlockError> {
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(), &self.salt).unwrap();
let decrypted = secretbox::open(&self.secret_key_enc, &self.nonce, &Key(key_buf))
.map_err(|_| UnlockError::BadPassphrase)?;
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)?;
@ -122,21 +143,18 @@ pub struct BaseCredentials {
}
impl BaseCredentials {
pub fn encrypt(&self, passphrase: &str) -> LockedCredentials {
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);
let nonce = secretbox::gen_nonce();
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 secret_key_enc = secretbox::seal(self.secret_access_key.as_bytes(), &nonce, &key);
LockedCredentials {
let locked = LockedCredentials {
access_key_id: self.access_key_id.clone(),
secret_key_enc,
salt,
nonce,
}
};
Ok(locked)
}
}
@ -144,9 +162,10 @@ impl BaseCredentials {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct SessionCredentials {
pub version: usize,
pub access_key_id: String,
pub secret_access_key: String,
pub token: String,
pub session_token: String,
#[serde(serialize_with = "serialize_expiration")]
#[serde(deserialize_with = "deserialize_expiration")]
pub expiration: DateTime,
@ -180,7 +199,7 @@ impl SessionCredentials {
let secret_access_key = aws_session.secret_access_key()
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let token = aws_session.session_token()
let session_token = aws_session.session_token()
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let expiration = aws_session.expiration()
@ -188,9 +207,10 @@ impl SessionCredentials {
.clone();
let session_creds = SessionCredentials {
version: 1,
access_key_id,
secret_access_key,
token,
session_token,
expiration,
};
@ -212,6 +232,14 @@ impl SessionCredentials {
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum Credentials {
Base(BaseCredentials),
Session(SessionCredentials),
}
fn serialize_expiration<S>(exp: &DateTime, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer
{
@ -241,4 +269,73 @@ 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)
}
}

View File

@ -1,6 +1,8 @@
use std::error::Error;
use std::convert::AsRef;
use std::ffi::OsString;
use std::sync::mpsc;
use std::string::FromUtf8Error;
use strum_macros::AsRefStr;
use thiserror::Error as ThisError;
@ -16,14 +18,20 @@ use tauri::api::dialog::{
MessageDialogBuilder,
MessageDialogKind,
};
use serde::{Serialize, Serializer, ser::SerializeMap};
use serde::{
Serialize,
Serializer,
ser::SerializeMap,
Deserialize,
};
pub trait ErrorPopup {
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) {
if let Err(e) = self {
let (tx, rx) = mpsc::channel();
@ -34,6 +42,14 @@ impl<E: Error> ErrorPopup for Result<(), E> {
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 +73,12 @@ where
E: Error,
M: serde::ser::SerializeMap,
{
let src = err.source().map(|s| format!("{s}"));
map.serialize_entry("source", &src)
let msg = err.source().map(|s| format!("{s}"));
map.serialize_entry("msg", &msg)?;
map.serialize_entry("code", &None::<&str>)?;
map.serialize_entry("source", &None::<&str>)?;
Ok(())
}
@ -90,6 +110,8 @@ pub enum SetupError {
ServerSetupError(#[from] std::io::Error),
#[error("Failed to resolve data directory: {0}")]
DataDir(#[from] DataDirError),
#[error("Failed to register hotkeys: {0}")]
RegisterHotkeys(#[from] tauri::Error),
}
@ -109,6 +131,8 @@ pub enum SendResponseError {
NotFound,
#[error("The specified request was already closed by the client")]
Abandoned,
#[error("A response has already been received for the specified request")]
Fulfilled,
#[error("Could not renew AWS sesssion: {0}")]
SessionRenew(#[from] GetSessionError),
}
@ -119,12 +143,14 @@ pub enum SendResponseError {
pub enum HandlerError {
#[error("Error writing to stream: {0}")]
StreamIOError(#[from] std::io::Error),
// #[error("Received invalid UTF-8 in request")]
// InvalidUtf8,
#[error("Received invalid UTF-8 in request")]
InvalidUtf8(#[from] FromUtf8Error),
#[error("HTTP request malformed")]
BadRequest(Vec<u8>),
BadRequest(#[from] serde_json::Error),
#[error("HTTP request too large")]
RequestTooLarge,
#[error("Internal server error")]
Internal,
#[error("Error accessing credentials: {0}")]
NoCredentials(#[from] GetCredentialsError),
#[error("Error getting client details: {0}")]
@ -133,6 +159,8 @@ pub enum HandlerError {
Tauri(#[from] tauri::Error),
#[error("No main application window found")]
NoMainWindow,
#[error("Request was denied")]
Denied,
}
@ -164,8 +192,8 @@ pub enum UnlockError {
NotLocked,
#[error("No saved credentials were found")]
NoCredentials,
#[error("Invalid passphrase")]
BadPassphrase,
#[error(transparent)]
Crypto(#[from] CryptoError),
#[error("Data was found to be corrupt after decryption")]
InvalidUtf8, // Somehow we got invalid utf-8 even though decryption succeeded
#[error("Database error: {0}")]
@ -175,41 +203,63 @@ 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
#[derive(Debug, ThisError, AsRefStr)]
pub enum ClientInfoError {
#[error("Found PID for client socket, but no corresponding process")]
ProcessNotFound,
#[error("Couldn't get client socket details: {0}")]
NetstatError(#[from] netstat2::error::Error),
#[error("Could not determine parent PID of connected client")]
ParentPidNotFound,
#[error("Found PID for parent process of client, but no corresponding process")]
ParentProcessNotFound,
#[error("Could not determine PID of connected client")]
WindowsError(#[from] windows::core::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
}
// Technically also an error, but formatted as a struct for easy deserialization
#[derive(Debug, Serialize, Deserialize)]
pub struct ServerError {
code: String,
msg: String,
}
impl std::fmt::Display for ServerError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
write!(f, "{} ({})", self.msg, self.code)?;
Ok(())
}
}
// 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("Error response from server: {0}")]
Server(ServerError),
#[error("Unexpected response from server")]
Unexpected(crate::server::Response),
#[error("The server did not respond with valid JSON")]
InvalidJson,
InvalidJson(#[from] serde_json::Error),
#[error("Error reading/writing stream: {0}")]
StreamIOError(#[from] std::io::Error),
#[error("Error loading configuration data: {0}")]
Setup(#[from] SetupError),
}
// Errors encountered while running a subprocess (via creddy exec)
#[derive(Debug, ThisError, AsRefStr)]
pub enum ExecError {
#[error("Please specify a command")]
NoCommand,
#[error("Failed to execute command: {0}")]
ExecutionFailed(#[from] std::io::Error)
impl From<ServerError> for RequestError {
fn from(s: ServerError) -> Self {
Self::Server(s)
}
}
@ -219,6 +269,35 @@ pub enum CliError {
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),
}
@ -252,13 +331,6 @@ impl Serialize for HandlerError {
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("code", self.as_ref())?;
map.serialize_entry("msg", &format!("{self}"))?;
match self {
HandlerError::NoCredentials(src) => map.serialize_entry("source", &src)?,
HandlerError::ClientInfo(src) => map.serialize_entry("source", &src)?,
_ => serialize_upstream_err(self, &mut map)?,
}
map.end()
}
}
@ -307,6 +379,38 @@ impl Serialize for UnlockError {
match self {
UnlockError::GetSession(src) => map.serialize_entry("source", &src)?,
// The string representation of the AEAD error is not very helpful, so skip it
UnlockError::Crypto(_src) => map.serialize_entry("source", &None::<&str>)?,
_ => serialize_upstream_err(self, &mut map)?,
}
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()

View File

@ -6,12 +6,13 @@ use crate::credentials::{Session,BaseCredentials};
use crate::errors::*;
use crate::clientinfo::Client;
use crate::state::AppState;
use crate::terminal;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Request {
pub struct AwsRequestNotification {
pub id: u64,
pub clients: Vec<Option<Client>>,
pub client: Client,
pub base: bool,
}
@ -78,3 +79,15 @@ pub async fn save_config(config: AppConfig, app_state: State<'_, AppState>) -> R
.map_err(|e| format!("Error saving config: {e}"))?;
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
View 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;

View File

@ -3,20 +3,11 @@
windows_subsystem = "windows"
)]
mod app;
mod cli;
mod config;
mod credentials;
mod errors;
mod clientinfo;
mod ipc;
mod state;
mod server;
mod tray;
use crate::errors::ErrorPopup;
use creddy::{
app,
cli,
errors::ErrorPopup,
};
fn main() {
@ -25,12 +16,13 @@ fn main() {
app::run().error_popup("Creddy failed to start");
Ok(())
},
Some(("show", m)) => cli::show(m),
Some(("get", m)) => cli::get(m),
Some(("exec", m)) => cli::exec(m),
_ => unreachable!(),
};
if let Err(e) = res {
eprintln!("Error: {e}");
std::process::exit(1);
}
}

View File

@ -1,243 +1,184 @@
use core::time::Duration;
use std::io;
use std::net::{
Ipv4Addr,
SocketAddr,
SocketAddrV4,
};
use tokio::net::{
TcpListener,
TcpStream,
use std::time::Duration;
#[cfg(windows)]
use tokio::net::windows::named_pipe::{
NamedPipeServer,
ServerOptions,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot;
use tokio::time::sleep;
use tauri::{AppHandle, Manager};
use tauri::async_runtime as rt;
use tauri::async_runtime::JoinHandle;
use serde::{Serialize, Deserialize};
use tauri::{
AppHandle,
Manager,
async_runtime as rt,
};
use crate::{clientinfo, clientinfo::Client};
use crate::errors::*;
use crate::ipc::{Request, Approval};
use crate::clientinfo::{self, Client};
use crate::credentials::Credentials;
use crate::ipc::{Approval, AwsRequestNotification};
use crate::state::AppState;
struct Handler {
request_id: u64,
stream: TcpStream,
receiver: Option<oneshot::Receiver<Approval>>,
app: AppHandle,
}
impl Handler {
async fn new(stream: TcpStream, app: AppHandle) -> Self {
let state = app.state::<AppState>();
let (chan_send, chan_recv) = oneshot::channel();
let request_id = state.register_request(chan_send).await;
Handler {
request_id,
stream,
receiver: Some(chan_recv),
app
}
}
async fn handle(mut self) {
if let Err(e) = self.try_handle().await {
eprintln!("{e}");
}
let state = self.app.state::<AppState>();
state.unregister_request(self.request_id).await;
}
async fn try_handle(&mut self) -> Result<(), HandlerError> {
let req_path = self.recv_request().await?;
let clients = self.get_clients().await?;
if self.includes_banned(&clients).await {
self.stream.write(b"HTTP/1.0 403 Access Denied\r\n\r\n").await?;
return Ok(())
}
let base = req_path == b"/creddy/base-credentials";
let req = Request {id: self.request_id, clients, base};
self.app.emit_all("credentials-request", &req)?;
let starting_visibility = self.show_window()?;
match self.wait_for_response().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 => {
let state = self.app.state::<AppState>();
for client in req.clients {
state.add_ban(client).await;
}
self.send_body(b"Denied!").await?;
self.stream.shutdown().await?;
}
}
// only hide the window if a) it was hidden to start with
// and b) there are no other pending requests
let state = self.app.state::<AppState>();
let delay = {
let config = state.config.read().await;
Duration::from_millis(config.rehide_ms)
};
sleep(delay).await;
if !starting_visibility && state.req_count().await == 0 {
let window = self.app.get_window("main").ok_or(HandlerError::NoMainWindow)?;
window.hide()?;
}
Ok(())
}
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 n = 0;
loop {
n += self.stream.read(&mut buf[n..]).await?;
if n >= 4 && &buf[(n - 4)..n] == b"\r\n\r\n" {break;}
if n == buf.len() {return Err(HandlerError::RequestTooLarge);}
}
let path = buf.split(|&c| &[c] == b" ")
.skip(1)
.next()
.ok_or(HandlerError::BadRequest(buf.clone()))?;
#[cfg(debug_assertions)] {
println!("Path: {}", std::str::from_utf8(&path).unwrap());
println!("{}", std::str::from_utf8(&buf).unwrap());
}
Ok(path.into())
}
async fn get_clients(&self) -> Result<Vec<Option<Client>>, HandlerError> {
let peer_addr = match self.stream.peer_addr()? {
SocketAddr::V4(addr) => addr,
_ => unreachable!(), // we only listen on IPv4
};
let clients = clientinfo::get_clients(peer_addr.port()).await?;
Ok(clients)
}
async fn includes_banned(&self, clients: &Vec<Option<Client>>) -> bool {
let state = self.app.state::<AppState>();
for client in clients {
if state.is_banned(client).await {
return true;
}
}
false
}
fn show_window(&self) -> Result<bool, HandlerError> {
let window = self.app.get_window("main").ok_or(HandlerError::NoMainWindow)?;
let starting_visibility = window.is_visible()?;
if !starting_visibility {
window.unminimize()?;
window.show()?;
}
window.set_focus()?;
Ok(starting_visibility)
}
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"Content-Type: application/json\r\n").await?;
self.stream.write(b"X-Creddy-delaying-tactic: ").await?;
#[allow(unreachable_code)] // seems necessary for type inference
let stall = async {
let delay = std::time::Duration::from_secs(1);
loop {
tokio::time::sleep(delay).await;
self.stream.write(b"x").await?;
}
Ok(Approval::Denied)
};
// this is the only place we even read this field, so it's safe to unwrap
let receiver = self.receiver.take().unwrap();
tokio::select!{
r = receiver => Ok(r.unwrap()), // only panics if the sender is dropped without sending, which shouldn't be possible
e = stall => e,
}
}
async fn send_body(&mut self, body: &[u8]) -> Result<(), HandlerError> {
self.stream.write(b"\r\nContent-Length: ").await?;
self.stream.write(body.len().to_string().as_bytes()).await?;
self.stream.write(b"\r\n\r\n").await?;
self.stream.write(body).await?;
self.stream.shutdown().await?;
Ok(())
}
#[derive(Serialize, Deserialize)]
pub enum Request {
GetAwsCredentials{
base: bool,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub enum Response {
Aws(Credentials)
}
#[derive(Debug)]
pub struct Server {
addr: Ipv4Addr,
port: u16,
listener: tokio::net::windows::named_pipe::NamedPipeServer,
app_handle: AppHandle,
task: JoinHandle<()>,
}
impl Server {
pub async fn new(addr: Ipv4Addr, port: u16, app_handle: AppHandle) -> io::Result<Server> {
let task = Self::start_server(addr, port, app_handle.app_handle()).await?;
Ok(Server { addr, port, app_handle, task})
}
pub fn start(app_handle: AppHandle) -> std::io::Result<()> {
let listener = ServerOptions::new()
.first_pipe_instance(true)
.create(r"\\.\pipe\creddy-requests")?;
pub async fn rebind(&mut self, addr: Ipv4Addr, port: u16) -> io::Result<()> {
if addr == self.addr && port == self.port {
return Ok(())
}
let new_task = Self::start_server(addr, port, self.app_handle.app_handle()).await?;
self.task.abort();
self.addr = addr;
self.port = port;
self.task = new_task;
let srv = Server {listener, app_handle};
rt::spawn(srv.serve());
Ok(())
}
// construct the listener before spawning the task so that we can return early if it fails
async fn start_server(addr: Ipv4Addr, port: u16, app_handle: AppHandle) -> io::Result<JoinHandle<()>> {
let sock_addr = SocketAddrV4::new(addr, port);
let listener = TcpListener::bind(&sock_addr).await?;
let task = rt::spawn(
Self::serve(listener, app_handle.app_handle())
);
Ok(task)
}
async fn serve(listener: TcpListener, app_handle: AppHandle) {
async fn serve(mut self) {
loop {
match listener.accept().await {
Ok((stream, _)) => {
let handler = Handler::new(stream, app_handle.app_handle()).await;
rt::spawn(handler.handle());
},
Err(e) => {
eprintln!("Error accepting connection: {e}");
}
if let Err(e) = self.try_serve().await {
eprintln!("Error accepting connection: {e}");
}
}
}
async fn try_serve(&mut self) -> std::io::Result<()> {
// connect() just waits for a client to connect, it doesn't return anything
self.listener.connect().await?;
// create a new pipe instance to listen for the next client, and swap it in
let new_listener = ServerOptions::new().create(r"\\.\pipe\creddy-requests")?;
let mut stream = std::mem::replace(&mut self.listener, new_listener);
let new_handle = self.app_handle.app_handle();
rt::spawn(async move {
let res = serde_json::to_string(
&handle(&mut stream, new_handle).await
).unwrap();
if let Err(e) = stream.write_all(res.as_bytes()).await {
eprintln!("Error responding to request: {e}");
}
});
Ok(())
}
}
async fn handle(stream: &mut NamedPipeServer, app_handle: AppHandle) -> Result<Response, HandlerError> {
// read from stream until delimiter is reached
let mut buf: Vec<u8> = Vec::with_capacity(1024); // requests are small, 1KiB is more than enough
let mut n = 0;
loop {
n += stream.read_buf(&mut buf).await?;
if let Some(&b'\n') = buf.last() {
break;
}
else if n >= 1024 {
return Err(HandlerError::RequestTooLarge);
}
}
let client = clientinfo::get_client_parent(&stream)?;
let req: Request = serde_json::from_slice(&buf)?;
match req {
Request::GetAwsCredentials{ base } => get_aws_credentials(base, client, app_handle).await,
// etc
}
}
async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle) -> Result<Response, HandlerError> {
let state = app_handle.state::<AppState>();
let main_window = app_handle.get_window("main").ok_or(HandlerError::NoMainWindow)?;
let is_currently_visible = main_window.is_visible()?;
let rehide_after = state.get_or_set_rehide(!is_currently_visible).await;
let (chan_send, chan_recv) = oneshot::channel();
let request_id = state.register_request(chan_send).await;
// if an error occurs in any of the following, we want to abort the operation
// but ? returns immediately, and we want to unregister the request before returning
// so we bundle it all up in an async block and return a Result so we can handle errors
let proceed = async {
let notification = AwsRequestNotification {id: request_id, client, base};
app_handle.emit_all("credentials-request", &notification)?;
if !main_window.is_visible()? {
main_window.unminimize()?;
main_window.show()?;
}
main_window.set_focus()?;
match chan_recv.await {
Ok(Approval::Approved) => {
if base {
let creds = state.base_creds_cloned().await?;
Ok(Response::Aws(Credentials::Base(creds)))
}
else {
let creds = state.session_creds_cloned().await?;
Ok(Response::Aws(Credentials::Session(creds)))
}
},
Ok(Approval::Denied) => Err(HandlerError::Denied),
Err(_e) => Err(HandlerError::Internal),
}
};
let result = match proceed.await {
Ok(r) => Ok(r),
Err(e) => {
state.unregister_request(request_id).await;
Err(e)
}
};
rt::spawn(
handle_rehide(rehide_after, app_handle.app_handle())
);
result
}
async fn handle_rehide(rehide_after: bool, app_handle: AppHandle) {
let state = app_handle.state::<AppState>();
let delay = {
let config = state.config.read().await;
Duration::from_millis(config.rehide_ms)
};
tokio::time::sleep(delay).await;
// if there are no other pending requests, set rehide status back to None
if state.req_count().await == 0 {
state.clear_rehide().await;
// and hide the window if necessary
if rehide_after {
app_handle.get_window("main").map(|w| {
if let Err(e) = w.hide() {
eprintln!("{e}");
}
});
}
}
}

View File

@ -0,0 +1,51 @@
use serde::{Serialize, Deserialize};
use tauri::{
AppHandle,
Manager,
};
use crate::app::APP;
use crate::config::HotkeysConfig;
use crate::terminal;
#[derive(Debug, Serialize, Deserialize)]
pub enum ShortcutAction {
ShowWindow,
LaunchTerminal,
}
pub fn exec_shortcut(action: ShortcutAction) {
match action {
ShowWindow => {
let app = APP.get().unwrap();
app.get_window("main").map(|w| w.show());
},
LaunchTerminal => terminal::launch(false),
}
}
pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> tauri::Result<()> {
let app = APP.get().unwrap();
let mut manager = app.global_shortcut_manager();
manager.unregister_all()?;
if hotkeys.show_window.enabled {
manager.register(
hotkeys.show_window.keys,
|| exec_shortcut(ShortcutAction::ShowWindow)
)?;
}
if hotkeys.launch_terminal.enabled {
manager.register(
&hotkeys.launch_terminal.keys,
|| exec_shortcut(ShortcutAction::LaunchTerminal)
)?;
}
Ok(())
}

View File

@ -1,16 +1,11 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use std::collections::HashMap;
use tokio::{
sync::oneshot::Sender,
sync::RwLock,
time::sleep,
sync::oneshot::Sender,
};
use sqlx::SqlitePool;
use tauri::async_runtime as runtime;
use tauri::Manager;
use crate::app::APP;
use crate::credentials::{
Session,
BaseCredentials,
@ -18,9 +13,7 @@ use crate::credentials::{
};
use crate::{config, config::AppConfig};
use crate::ipc::{self, Approval};
use crate::clientinfo::Client;
use crate::errors::*;
use crate::server::Server;
#[derive(Debug)]
@ -28,27 +21,35 @@ pub struct AppState {
pub config: RwLock<AppConfig>,
pub session: RwLock<Session>,
pub request_count: RwLock<u64>,
pub open_requests: RwLock<HashMap<u64, Sender<ipc::Approval>>>,
pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
server: RwLock<Server>,
pub waiting_requests: RwLock<HashMap<u64, Sender<Approval>>>,
pub current_rehide_status: RwLock<Option<bool>>,
pub pending_terminal_request: RwLock<bool>,
// setup_errors is never modified and so doesn't need to be wrapped in RwLock
pub setup_errors: Vec<String>,
pool: sqlx::SqlitePool,
}
impl AppState {
pub fn new(config: AppConfig, session: Session, server: Server, pool: SqlitePool) -> AppState {
pub fn new(
config: AppConfig,
session: Session,
pool: SqlitePool,
setup_errors: Vec<String>,
) -> AppState {
AppState {
config: RwLock::new(config),
session: RwLock::new(session),
request_count: RwLock::new(0),
open_requests: RwLock::new(HashMap::new()),
bans: RwLock::new(HashSet::new()),
server: RwLock::new(server),
waiting_requests: RwLock::new(HashMap::new()),
current_rehide_status: RwLock::new(None),
pending_terminal_request: RwLock::new(false),
setup_errors,
pool,
}
}
pub async fn new_creds(&self, base_creds: BaseCredentials, passphrase: &str) -> Result<(), UnlockError> {
let locked = base_creds.encrypt(passphrase);
let locked = base_creds.encrypt(passphrase)?;
// do this first so that if it fails we don't save bad credentials
self.new_session(base_creds).await?;
locked.save(&self.pool).await?;
@ -59,14 +60,16 @@ impl AppState {
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
let mut live_config = self.config.write().await;
// update autostart if necessary
if new_config.start_on_login != live_config.start_on_login {
config::set_auto_launch(new_config.start_on_login)?;
}
if new_config.listen_addr != live_config.listen_addr
|| new_config.listen_port != live_config.listen_port
// 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
{
let mut sv = self.server.write().await;
sv.rebind(new_config.listen_addr, new_config.listen_port).await?;
config::register_hotkeys(&new_config.hotkeys)?;
}
new_config.save(&self.pool).await?;
@ -74,26 +77,42 @@ impl AppState {
Ok(())
}
pub async fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
pub async fn register_request(&self, sender: Sender<Approval>) -> u64 {
let count = {
let mut c = self.request_count.write().await;
*c += 1;
c
};
let mut open_requests = self.open_requests.write().await;
open_requests.insert(*count, chan); // `count` is the request id
let mut waiting_requests = self.waiting_requests.write().await;
waiting_requests.insert(*count, sender); // `count` is the request id
*count
}
pub async fn unregister_request(&self, id: u64) {
let mut open_requests = self.open_requests.write().await;
open_requests.remove(&id);
let mut waiting_requests = self.waiting_requests.write().await;
waiting_requests.remove(&id);
}
pub async fn req_count(&self) -> usize {
let open_requests = self.open_requests.read().await;
open_requests.len()
let waiting_requests = self.waiting_requests.read().await;
waiting_requests.len()
}
pub async fn get_or_set_rehide(&self, new_value: bool) -> bool {
let mut rehide = self.current_rehide_status.write().await;
match *rehide {
Some(original) => original,
None => {
*rehide = Some(new_value);
new_value
}
}
}
pub async fn clear_rehide(&self) {
let mut rehide = self.current_rehide_status.write().await;
*rehide = None;
}
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
@ -102,31 +121,12 @@ impl AppState {
session.renew_if_expired().await?;
}
let mut open_requests = self.open_requests.write().await;
let chan = open_requests
let mut waiting_requests = self.waiting_requests.write().await;
waiting_requests
.remove(&response.id)
.ok_or(SendResponseError::NotFound)
?;
chan.send(response.approval)
.map_err(|_e| SendResponseError::Abandoned)
}
pub async fn add_ban(&self, client: Option<Client>) {
let mut bans = self.bans.write().await;
bans.insert(client.clone());
runtime::spawn(async move {
sleep(Duration::from_secs(5)).await;
let app = APP.get().unwrap();
let state = app.state::<AppState>();
let mut bans = state.bans.write().await;
bans.remove(&client);
});
}
pub async fn is_banned(&self, client: &Option<Client>) -> bool {
self.bans.read().await.contains(&client)
.ok_or(SendResponseError::NotFound)?
.send(response.approval)
.map_err(|_| SendResponseError::Abandoned)
}
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
@ -141,22 +141,21 @@ impl AppState {
Ok(())
}
pub async fn serialize_base_creds(&self) -> Result<String, GetCredentialsError> {
pub async fn is_unlocked(&self) -> bool {
let session = self.session.read().await;
match *session {
Session::Unlocked{ref base, ..} => Ok(serde_json::to_string(base).unwrap()),
Session::Locked(_) => Err(GetCredentialsError::Locked),
Session::Empty => Err(GetCredentialsError::Empty),
}
matches!(*session, Session::Unlocked{..})
}
pub async fn serialize_session_creds(&self) -> Result<String, GetCredentialsError> {
let session = self.session.read().await;
match *session {
Session::Unlocked{ref session, ..} => Ok(serde_json::to_string(session).unwrap()),
Session::Locked(_) => Err(GetCredentialsError::Locked),
Session::Empty => Err(GetCredentialsError::Empty),
}
pub async fn base_creds_cloned(&self) -> Result<BaseCredentials, GetCredentialsError> {
let app_session = self.session.read().await;
let (base, _session) = app_session.try_get()?;
Ok(base.clone())
}
pub async fn session_creds_cloned(&self) -> Result<SessionCredentials, GetCredentialsError> {
let app_session = self.session.read().await;
let (_bsae, session) = app_session.try_get()?;
Ok(session.clone())
}
async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> {
@ -165,4 +164,21 @@ impl AppState {
*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(())
}
}
pub async fn unregister_terminal_request(&self) {
let mut req = self.pending_terminal_request.write().await;
*req = false;
}
}

82
src-tauri/src/terminal.rs Normal file
View 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.session_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(())
}

View File

@ -8,11 +8,12 @@
},
"package": {
"productName": "creddy",
"version": "0.2.0"
"version": "0.3.3"
},
"tauri": {
"allowlist": {
"os": {"all": true}
"os": {"all": true},
"dialog": {"open": true}
},
"bundle": {
"active": true,
@ -44,7 +45,11 @@
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
"timestampUrl": "",
"wix": {
"fragmentPaths": ["conf/cli.wxs"],
"componentRefs": ["CliBinary", "AddToPath"]
}
}
},
"security": {

View File

@ -15,7 +15,25 @@ invoke('get_config').then(config => $appState.config = config);
listen('credentials-request', (tauriEvent) => {
$appState.pendingRequests.put(tauriEvent.payload);
});
window.state = $appState;
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();
</script>

View File

@ -9,6 +9,10 @@ export default function() {
resolvers: [],
size() {
return this.items.length;
},
put(item) {
this.items.push(item);
let resolver = this.resolvers.shift();

View File

@ -8,6 +8,7 @@ export let appState = writable({
currentRequest: null,
pendingRequests: queue(),
credentialStatus: 'locked',
setupErrors: [],
});

13
src/ui/KeyCombo.svelte Normal file
View 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
View 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>

View 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>

View 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>

View File

@ -5,6 +5,7 @@
export let title;
export let value;
export let unit = '';
export let min = null;
export let max = null;

View File

@ -6,14 +6,17 @@
</script>
<div class="divider"></div>
<div class="flex justify-between">
<h3 class="text-lg font-bold">{title}</h3>
<slot name="input"></slot>
</div>
<div>
<div class="flex flex-wrap justify-between gap-y-4">
<h3 class="text-lg font-bold shrink-0">{title}</h3>
{#if $$slots.input}
<slot name="input"></slot>
{/if}
</div>
{#if $$slots.description}
<p class="mt-3">
<slot name="description"></slot>
</p>
{/if}
{#if $$slots.description}
<p class="mt-3">
<slot name="description"></slot>
</p>
{/if}
</div>

View 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>

View 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>

View File

@ -1,3 +1,5 @@
export { default as Setting } from './Setting.svelte';
export { default as ToggleSetting } from './ToggleSetting.svelte';
export { default as NumericSetting } from './NumericSetting.svelte';
export { default as FileSetting } from './FileSetting.svelte';
export { default as TextSetting } from './TextSetting.svelte';

View File

@ -6,6 +6,7 @@
import { appState, completeRequest } from '../lib/state.js';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte';
import KeyCombo from '../ui/KeyCombo.svelte';
// Send response to backend, display error if applicable
@ -46,16 +47,13 @@
}
// Extract executable name from full path
let appName = null;
if ($appState.currentRequest.clients.length === 1) {
let path = $appState.currentRequest.clients[0].exe;
let m = path.match(/\/([^/]+?$)|\\([^\\]+?$)/);
appName = m[1] || m[2];
}
const client = $appState.currentRequest.client;
const m = client.exe?.match(/\/([^/]+?$)|\\([^\\]+?$)/);
const appName = m[1] || m[2];
// Executable paths can be long, so ensure they only break on \ or /
function breakPath(client) {
return client.exe.replace(/(\\|\/)/g, '$1<wbr>');
function breakPath(path) {
return path.replace(/(\\|\/)/g, '$1<wbr>');
}
// if the request has already been approved/denied, send response immediately
@ -96,29 +94,25 @@
<h2 class="text-xl font-bold">{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.</h2>
<div class="grid grid-cols-[auto_1fr] gap-x-3">
{#each $appState.currentRequest.clients as client}
<div class="text-right">Path:</div>
<code class="">{@html client ? breakPath(client) : 'Unknown'}</code>
<div class="text-right">PID:</div>
<code>{client ? client.pid : 'Unknown'}</code>
{/each}
<div class="text-right">Path:</div>
<code class="">{@html client.exe ? breakPath(client.exe) : 'Unknown'}</code>
<div class="text-right">PID:</div>
<code>{client.pid}</code>
</div>
</div>
<div class="w-full flex justify-between">
<Link target={deny} hotkey="Escape">
<button class="btn btn-error justify-self-start">
Deny
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Esc</kbd>
<span class="mr-2">Deny</span>
<KeyCombo keys={['Esc']} />
</button>
</Link>
<Link target={approve} hotkey="Enter" shift="{true}">
<button class="btn btn-success justify-self-end">
Approve
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Shift</kbd>
<span class="mx-0.5">+</span>
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">Enter</kbd>
<span class="mr-2">Approve</span>
<KeyCombo keys={['Shift', 'Enter']} />
</button>
</Link>
</div>

View File

@ -7,6 +7,7 @@
import { navigate } from '../lib/routing.js';
import Link from '../ui/Link.svelte';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Spinner from '../ui/Spinner.svelte';
let errorMsg = null;
@ -19,6 +20,7 @@
}
}
let saving = false;
async function save() {
if (passphrase !== confirmPassphrase) {
alert.shake();
@ -27,7 +29,9 @@
let credentials = {AccessKeyId, SecretAccessKey};
try {
saving = true;
await invoke('save_credentials', {credentials, passphrase});
emit('credentials-event', 'entered');
if ($appState.currentRequest) {
navigate('Approve');
}
@ -36,19 +40,28 @@
}
}
catch (e) {
if (e.code === "GetSession") {
let root = getRootCause(e);
window.error = e;
const root = getRootCause(e);
if (e.code === 'GetSession' && root.code) {
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
}
else {
errorMsg = e.msg;
}
// if the alert already existed, shake it
if (alert) {
alert.shake();
}
saving = false;
}
}
function cancel() {
emit('credentials-event', 'enter-canceled');
navigate('Home');
}
</script>
@ -65,8 +78,14 @@
<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="submit" class="btn btn-primary" />
<Link target="Home" hotkey="Escape">
<button type="submit" class="btn btn-primary">
{#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>
</Link>
</form>

View File

@ -10,13 +10,11 @@
import vaultDoorSvg from '../assets/vault_door.svg?raw';
// onMount(async () => {
// // will block until a request comes in
// let req = await $appState.pendingRequests.get();
// $appState.currentRequest = req;
// navigate('Approve');
// });
let launchBase = false;
function launchTerminal() {
invoke('launch_terminal', {base: launchBase});
launchBase = false;
}
</script>
@ -25,25 +23,45 @@
</Nav>
<div class="flex flex-col h-screen items-center justify-center p-4 space-y-4">
{#await invoke('get_session_status') then status}
{#if status === 'locked'}
<div class="flex flex-col items-center space-y-4">
{@html vaultDoorSvg}
{#await invoke('get_session_status') then status}
{#if status === 'locked'}
{@html vaultDoorSvg}
<h2 class="text-2xl font-bold">Creddy is locked</h2>
<Link target="Unlock" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Unlock</button>
</Link>
<h2 class="text-2xl font-bold">Creddy is locked</h2>
<Link target="Unlock" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Unlock</button>
</Link>
{:else if status === 'unlocked'}
{@html vaultDoorSvg}
<h2 class="text-2xl font-bold">Waiting for requests</h2>
{:else if status === 'unlocked'}
<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'}
{@html vaultDoorSvg}
<h2 class="text-2xl font-bold">No credentials found</h2>
<Link target="EnterCredentials" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Enter Credentials</button>
</Link>
{/if}
{/await}
</div>
{:else if status === 'empty'}
<h2 class="text-2xl font-bold">No credentials found</h2>
<Link target="EnterCredentials" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Enter Credentials</button>
</Link>
{/if}
{/await}
</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}

View File

@ -6,7 +6,9 @@
import Nav from '../ui/Nav.svelte';
import Link from '../ui/Link.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 { backInOut } from 'svelte/easing';
@ -14,6 +16,7 @@
let error = null;
async function save() {
console.log('updating config');
try {
await invoke('save_config', {config: $appState.config});
}
@ -23,59 +26,69 @@
}
}
let osType = '';
let osType = null;
type().then(t => osType = t);
</script>
<Nav>
<h2 slot="title" class="text-2xl font-bold">Settings</h2>
<h1 slot="title" class="text-2xl font-bold">Settings</h1>
</Nav>
{#await invoke('get_config') then config}
<div class="max-w-md mx-auto mt-1.5 p-4">
<!-- <h2 class="text-2xl font-bold text-center">Settings</h2> -->
<div class="max-w-lg mx-auto mt-1.5 p-4 space-y-16">
<SettingsGroup name="General">
<ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}>
<svelte:fragment slot="description">
Start Creddy when you log in to your computer.
</svelte:fragment>
</ToggleSetting>
<ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}>
<svelte:fragment slot="description">
Start Creddy when you log in to your computer.
</svelte:fragment>
</ToggleSetting>
<ToggleSetting title="Start minimized" bind:value={$appState.config.start_minimized} on:update={save}>
<svelte:fragment slot="description">
Minimize to the system tray at startup.
</svelte:fragment>
</ToggleSetting>
<ToggleSetting title="Start minimized" bind:value={$appState.config.start_minimized} on:update={save}>
<svelte:fragment slot="description">
Minimize to the system tray at startup.
</svelte:fragment>
</ToggleSetting>
<NumericSetting title="Re-hide delay" bind:value={$appState.config.rehide_ms} min={0} unit="Milliseconds" on:update={save}>
<svelte:fragment slot="description">
How long to wait after a request is approved/denied before minimizing
the window to tray. Only applicable if the window was minimized
to tray before the request was received.
</svelte:fragment>
</NumericSetting>
<NumericSetting title="Re-hide delay" bind:value={$appState.config.rehide_ms} min={0} unit="Milliseconds" on:update={save}>
<svelte:fragment slot="description">
How long to wait after a request is approved/denied before minimizing
the window to tray. Only applicable if the window was minimized
to tray before the request was received.
</svelte:fragment>
</NumericSetting>
<Setting title="Update credentials">
<Link slot="input" target="EnterCredentials">
<button class="btn btn-sm btn-primary">Update</button>
</Link>
<svelte:fragment slot="description">
Update or re-enter your encrypted credentials.
</svelte:fragment>
</Setting>
<NumericSetting
title="Listen port"
bind:value={$appState.config.listen_port}
min={osType === 'Windows_NT' ? 1 : 0}
on:update={save}
>
<svelte:fragment slot="description">
Listen for credentials requests on this port.
(Should be used with <code>$AWS_CONTAINER_CREDENTIALS_FULL_URI</code>)
</svelte:fragment>
</NumericSetting>
<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>
<Setting title="Update credentials">
<Link slot="input" target="EnterCredentials">
<button class="btn btn-sm btn-primary">Update</button>
</Link>
<svelte:fragment slot="description">
Update or re-enter your encrypted credentials.
</svelte:fragment>
</Setting>
</div>
{/await}

View File

@ -1,5 +1,6 @@
<script>
import { invoke } from '@tauri-apps/api/tauri';
import { emit } from '@tauri-apps/api/event';
import { onMount } from 'svelte';
import { appState } from '../lib/state.js';
@ -7,12 +8,14 @@
import { getRootCause } from '../lib/errors.js';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte';
import Spinner from '../ui/Spinner.svelte';
let errorMsg = null;
let alert;
let passphrase = '';
let loadTime = 0;
let saving = false;
async function unlock() {
// The hotkey for navigating here from homepage is Enter, which also
// happens to trigger the form submit event
@ -21,8 +24,10 @@
}
try {
saving = true;
let r = await invoke('unlock', {passphrase});
$appState.credentialStatus = 'unlocked';
emit('credentials-event', 'unlocked');
if ($appState.currentRequest) {
navigate('Approve');
}
@ -31,21 +36,28 @@
}
}
catch (e) {
window.error = e;
if (e.code === 'GetSession') {
let root = getRootCause(e);
const root = getRootCause(e);
if (e.code === 'GetSession' && root.code) {
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
}
else {
errorMsg = e.msg;
}
// if the alert already existed, shake it
if (alert) {
alert.shake();
}
saving = false;
}
}
function cancel() {
emit('credentials-event', 'unlock-canceled');
navigate('Home');
}
onMount(() => {
loadTime = Date.now();
})
@ -62,8 +74,15 @@
<!-- svelte-ignore a11y-autofocus -->
<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" />
<Link target="Home" hotkey="Escape">
<button class="btn btn-outline btn-sm w-full">Cancel</button>
<button type="submit" class="btn btn-primary">
{#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>
</Link>
</form>