51 Commits
dev2 ... wip

Author SHA1 Message Date
d650909ac7 switch crypto implementation and add spinner 2023-05-08 22:11:58 -07:00
663bbfa2f3 start switching crypto implementations 2023-05-07 21:14:07 -07:00
e866a4a643 change location of dev db and bump version 2023-05-06 22:09:41 -07:00
94400ba7d5 get host addr/port from database when requesting credentials 2023-05-06 16:56:45 -07:00
616600687d add show/exec commands and refactor AppState 2023-05-06 12:01:56 -07:00
e8b8dc2976 cargo update 2023-05-02 15:24:46 -07:00
ddf865d0b4 switch to tokio RwLock instead of std 2023-05-02 15:24:35 -07:00
96bbc2dbc2 session renewal 2023-05-02 11:33:18 -07:00
161148d1f6 store base credentials as well as session credentials 2023-05-01 23:03:34 -07:00
760987f09b show approval errors in approval view 2023-05-01 16:53:24 -07:00
a75f34865e return to previous view after approval flow 2023-05-01 13:27:28 -07:00
886fcd9bb8 restrictive CSP and tauri allowlist 2023-05-01 09:05:46 -07:00
55775b6b05 move error dialog to trait 2023-04-30 14:10:21 -07:00
871dedf0a3 display setup errors 2023-04-30 10:52:46 -07:00
913148a75a minor tweaks 2023-04-29 10:01:45 -07:00
e746963052 change frontpage image and toast animation 2023-04-28 22:34:50 -07:00
b761d3b493 find data dir properly 2023-04-28 22:34:17 -07:00
c5dcc2e50a handle errors on config update 2023-04-28 14:33:23 -07:00
70d71ce14e restart listener when config changes 2023-04-28 14:33:04 -07:00
33a5600a30 prevent NumericSetting from accepting non-numeric inputs 2023-04-27 16:15:19 -07:00
741169d807 start on login 2023-04-27 14:24:08 -07:00
ebc00a5df6 confirm passphrase 2023-04-26 17:13:58 -07:00
c2cc007a81 display tweaks and approval page timing 2023-04-26 17:06:37 -07:00
4aab08e6f0 save settings to db 2023-04-26 15:49:08 -07:00
12d9d733a5 fix circular imports from routing 2023-04-26 13:05:51 -07:00
35271049dd settings page 2023-04-25 22:10:28 -07:00
6f9cd6b471 move app state to store 2023-04-25 08:49:00 -07:00
865b7fd5c4 add settings page 2023-04-24 22:18:55 -07:00
f35352eedd links, navs, and more 2023-04-24 22:16:25 -07:00
53580d7919 rework routing 2023-04-24 12:05:11 -07:00
049b81610d rewrite frontend with DaisyUI 2023-04-23 22:29:12 -07:00
fd60899f16 don't re-hide when a request comes in while showing approval screen 2023-04-21 11:18:20 -07:00
e0c4c849dc serializable structured errors 2022-12-29 16:40:48 -08:00
cb26201506 unproductive flailing 2022-12-28 15:48:25 -08:00
992e3c8db2 build improvements 2022-12-28 10:16:06 -08:00
4956b64371 only print incoming requests in debug mode 2022-12-28 08:54:08 -08:00
df6b362a31 return structured errors from commands (wip) 2022-12-23 11:34:17 -08:00
2943634248 button component 2022-12-22 21:50:09 -08:00
06f5a1af42 icon picker component 2022-12-22 19:53:14 -08:00
61d674199f store config in database, macro for state access 2022-12-22 16:36:32 -08:00
398916fe10 use different ports for dev and live modes 2022-12-21 16:22:24 -08:00
bf4c46238e move re-hide to main request handler 2022-12-21 16:04:12 -08:00
5ffa55c03c basic system tray functionality 2022-12-21 14:49:01 -08:00
50f0985f4f display client info and unlock errors to user 2022-12-21 13:43:37 -08:00
69475604c0 update npm deps 2022-12-21 11:02:19 -08:00
856b6f1e1b use thiserror for errors 2022-12-21 11:01:34 -08:00
414379b74e completely reorganize http server 2022-12-20 16:11:49 -08:00
80b92ebe69 generalize pid conversion 2022-12-20 13:01:44 -08:00
983d0e8639 autofocus passphrase field in unlock view 2022-12-19 20:45:26 -08:00
d77437cda8 ban list 2022-12-19 16:20:46 -08:00
3d5cbedae1 working basic flow 2022-12-19 15:26:44 -08:00
51 changed files with 5587 additions and 2227 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ src-tauri/target/
# just in case
credentials*
!credentials.rs

View File

@ -1,25 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Svelte</title>
<style>
body {
margin: 0;
display: grid;
align-items: center;
justify-items: center;
min-width: 100vw;
min-height: 100vh;
}
</style>
</head>
<body class="bg-zinc-800">
<div id="app"></div>
<body id="app" class="m-0">
<script type="module" src="/src/main.js"></script>
</body>
</html>

1727
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "creddy",
"version": "0.1.0",
"version": "0.2.1",
"scripts": {
"dev": "vite",
"build": "vite build",
@ -17,6 +17,7 @@
"vite": "^3.0.7"
},
"dependencies": {
"@tauri-apps/api": "^1.0.2"
"@tauri-apps/api": "^1.0.2",
"daisyui": "^2.51.5"
}
}

View File

@ -0,0 +1,7 @@
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=--ld-path=/usr/bin/mold"]
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

1
src-tauri/.env Normal file
View File

@ -0,0 +1 @@
DATABASE_URL=sqlite://C:/Users/Joe/AppData/Roaming/creddy/creddy.dev.db

2930
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.1.0"
version = "0.2.0"
description = "A Tauri App"
authors = ["you"]
license = ""
@ -17,17 +17,35 @@ tauri-build = { version = "1.0.4", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.0.5", features = ["api-all"] }
tauri = { version = "1.2", features = ["dialog", "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"
aws-smithy-types = "0.52.0"
aws-config = "0.52.0"
thiserror = "1.0.38"
once_cell = "1.16.0"
strum = "0.24"
strum_macros = "0.24"
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"] }
[features]
# by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
default = [ "custom-protocol" ]
default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = [ "tauri/custom-protocol" ]
custom-protocol = ["tauri/custom-protocol"]
# [profile.dev.build-override]
# opt-level = 3

View File

@ -3,12 +3,13 @@ CREATE TABLE credentials (
access_key_id TEXT NOT NULL,
secret_key_enc BLOB NOT NULL,
salt BLOB NOT NULL,
nonce BLOB NOT NULL
nonce BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE config (
name TEXT,
data TEXT
name TEXT UNIQUE NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE clients (

92
src-tauri/src/app.rs Normal file
View File

@ -0,0 +1,92 @@
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,
])
.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();
let pool = connect_db().await?;
let conf = AppConfig::load(&pool).await?;
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 !conf.start_minimized {
app.get_window("main")
.ok_or(HandlerError::NoMainWindow)?
.show()?;
}
let state = AppState::new(conf, session, srv, pool);
app.manage(state);
Ok(())
}

142
src-tauri/src/cli.rs Normal file
View File

@ -0,0 +1,142 @@
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")
.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().map_err(|e| ExecError::ExecutionFailed(e))?;
#[cfg(windows)]
{
let mut child = cmd.spawn()
.map_err(|e| ExecError::ExecutionFailed(e))?;
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)
}

View File

@ -1,17 +1,38 @@
use std::path::PathBuf;
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
use sysinfo::{System, SystemExt, Pid, ProcessExt};
use tauri::Manager;
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
use serde::{Serialize, Deserialize};
use crate::errors::*;
use crate::ipc::Client;
use crate::{
app::APP,
errors::*,
config::AppConfig,
state::AppState,
};
fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
let mut it = netstat2::iterate_sockets_info(
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
pub struct Client {
pub pid: u32,
pub exe: 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 (i, item) in it.enumerate() {
for item in sockets_iter {
let sock_info = item?;
let proto_info = match sock_info.protocol_socket_info {
ProtocolSocketInfo::Tcp(tcp_info) => tcp_info,
@ -19,9 +40,9 @@ fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Err
};
if proto_info.local_port == local_port
&& proto_info.remote_port == 12345
&& proto_info.local_addr == std::net::Ipv4Addr::LOCALHOST
&& proto_info.remote_addr == std::net::Ipv4Addr::LOCALHOST
&& 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)
}
@ -30,22 +51,26 @@ fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Err
}
// Theoretically, on some systems, multiple processes can share a socket. We have to
// account for this even though 99% of the time there will be only one.
pub fn get_clients(local_port: u16) -> Result<Vec<Client>, ClientInfoError> {
// 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)? {
let pid = Pid::from(p as usize);
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::PidNotFound)?;
.ok_or(ClientInfoError::ProcessNotFound)?;
let client = Client {
pid: p,
exe: proc.exe().to_string_lossy().into_owned(),
exe: proc.exe().to_path_buf(),
};
clients.push(client);
clients.push(Some(client));
}
if clients.is_empty() {
clients.push(None);
}
Ok(clients)
}

123
src-tauri/src/config.rs Normal file
View File

@ -0,0 +1,123 @@
use std::net::Ipv4Addr;
use std::path::PathBuf;
use auto_launch::AutoLaunchBuilder;
use is_terminal::IsTerminal;
use serde::{Serialize, Deserialize};
use sqlx::SqlitePool;
use crate::errors::*;
#[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,
}
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(),
}
}
}
impl AppConfig {
pub async fn load(pool: &SqlitePool) -> Result<AppConfig, SetupError> {
let res = sqlx::query!("SELECT * from config where name = 'main'")
.fetch_optional(pool)
.await?;
let row = match res {
Some(row) => row,
None => return Ok(AppConfig::default()),
};
Ok(serde_json::from_str(&row.data)?)
}
pub async fn save(&self, pool: &SqlitePool) -> Result<(), sqlx::error::Error> {
let data = serde_json::to_string(self).unwrap();
sqlx::query(
"INSERT INTO config (name, data) VALUES ('main', ?)
ON CONFLICT (name) DO UPDATE SET data = ?"
)
.bind(&data)
.bind(&data)
.execute(pool)
.await?;
Ok(())
}
}
pub fn set_auto_launch(is_configured: bool) -> Result<(), SetupError> {
let path_buf = std::env::current_exe()
.map_err(|e| auto_launch::Error::Io(e))?;
let path = path_buf
.to_string_lossy();
let auto = AutoLaunchBuilder::new()
.set_app_name("Creddy")
.set_app_path(&path)
.build()?;
let is_enabled = auto.is_enabled()?;
if is_configured && !is_enabled {
auto.enable()?;
}
else if !is_configured && is_enabled {
auto.disable()?;
}
Ok(())
}
pub fn get_or_create_db_path() -> Result<PathBuf, DataDirError> {
let mut path = dirs::data_dir()
.ok_or(DataDirError::NotFound)?;
path.push("Creddy");
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");
}
Ok(path)
}
fn default_listen_port() -> u16 {
if cfg!(debug_assertions) {
12_345
}
else {
19_923
}
}
fn default_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST }
fn default_rehide_ms() -> u64 { 1000 }
// start minimized and on login only in production mode
fn default_start_minimized() -> bool { !cfg!(debug_assertions) }
fn default_start_on_login() -> bool { !cfg!(debug_assertions) }

View File

@ -0,0 +1,321 @@
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),
}
}
}
#[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 in an unoptimized build,
/// 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,144 +1,323 @@
use std::fmt::{Display, Formatter};
use std::convert::From;
use std::str::Utf8Error;
use std::error::Error;
use std::convert::AsRef;
use std::sync::mpsc;
use strum_macros::AsRefStr;
use thiserror::Error as ThisError;
use aws_sdk_sts::{
types::SdkError as AwsSdkError,
error::GetSessionTokenError,
};
use sqlx::{
error::Error as SqlxError,
migrate::MigrateError,
};
use tauri::api::dialog::{
MessageDialogBuilder,
MessageDialogKind,
};
use serde::{Serialize, Serializer, ser::SerializeMap};
pub trait ErrorPopup {
fn error_popup(self, title: &str);
}
impl<E: Error> ErrorPopup for Result<(), E> {
fn error_popup(self, title: &str) {
if let Err(e) = self {
let (tx, rx) = mpsc::channel();
MessageDialogBuilder::new(title, format!("{e}"))
.kind(MessageDialogKind::Error)
.show(move |_| tx.send(true).unwrap());
rx.recv().unwrap();
}
}
}
fn serialize_basic_err<E, S>(err: &E, serializer: S) -> Result<S::Ok, S::Error>
where
E: std::error::Error + AsRef<str>,
S: Serializer,
{
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("code", err.as_ref())?;
map.serialize_entry("msg", &format!("{err}"))?;
if let Some(src) = err.source() {
map.serialize_entry("source", &format!("{src}"))?;
}
map.end()
}
fn serialize_upstream_err<E, M>(err: &E, map: &mut M) -> Result<(), M::Error>
where
E: Error,
M: serde::ser::SerializeMap,
{
let src = err.source().map(|s| format!("{s}"));
map.serialize_entry("source", &src)
}
macro_rules! impl_serialize_basic {
($err_type:ident) => {
impl Serialize for $err_type {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serialize_basic_err(self, serializer)
}
}
}
}
// error during initial setup (primarily loading state from db)
#[derive(Debug, ThisError, AsRefStr)]
pub enum SetupError {
#[error("Invalid database record")]
InvalidRecord, // e.g. wrong size blob for nonce or salt
DbError(SqlxError),
}
impl From<SqlxError> for SetupError {
fn from(e: SqlxError) -> SetupError {
SetupError::DbError(e)
}
}
impl From<MigrateError> for SetupError {
fn from (e: MigrateError) -> SetupError {
SetupError::DbError(SqlxError::from(e))
}
}
impl Display for SetupError {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
match self {
SetupError::InvalidRecord => write!(f, "Malformed database record"),
SetupError::DbError(e) => write!(f, "Error from database: {e}"),
}
}
#[error("Error from database: {0}")]
DbError(#[from] SqlxError),
#[error("Error running migrations: {0}")]
MigrationError(#[from] MigrateError),
#[error("Error parsing configuration from database")]
ConfigParseError(#[from] serde_json::Error),
#[error("Failed to set up start-on-login: {0}")]
AutoLaunchError(#[from] auto_launch::Error),
#[error("Failed to start listener: {0}")]
ServerSetupError(#[from] std::io::Error),
#[error("Failed to resolve data directory: {0}")]
DataDir(#[from] DataDirError),
}
// error when attempting to tell a request handler whether to release or deny crednetials
#[derive(Debug, ThisError, AsRefStr)]
pub enum DataDirError {
#[error("Could not determine data directory")]
NotFound,
#[error("Failed to create data directory: {0}")]
Io(#[from] std::io::Error),
}
// error when attempting to tell a request handler whether to release or deny credentials
#[derive(Debug, ThisError, AsRefStr)]
pub enum SendResponseError {
NotFound, // no request with the given id
Abandoned, // request has already been closed by client
}
impl Display for SendResponseError {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
use SendResponseError::*;
match self {
NotFound => write!(f, "The specified command was not found."),
Abandoned => write!(f, "The specified request was closed by the client."),
}
}
#[error("The specified credentials request was not found")]
NotFound,
#[error("The specified request was already closed by the client")]
Abandoned,
#[error("Could not renew AWS sesssion: {0}")]
SessionRenew(#[from] GetSessionError),
}
// errors encountered while handling an HTTP request
pub enum RequestError {
StreamIOError(std::io::Error),
InvalidUtf8,
MalformedHttpRequest,
#[derive(Debug, ThisError, AsRefStr)]
pub enum HandlerError {
#[error("Error writing to stream: {0}")]
StreamIOError(#[from] std::io::Error),
// #[error("Received invalid UTF-8 in request")]
// InvalidUtf8,
#[error("HTTP request malformed")]
BadRequest(Vec<u8>),
#[error("HTTP request too large")]
RequestTooLarge,
NoCredentials(GetCredentialsError),
ClientInfo(ClientInfoError),
}
impl From<tokio::io::Error> for RequestError {
fn from(e: std::io::Error) -> RequestError {
RequestError::StreamIOError(e)
}
}
impl From<Utf8Error> for RequestError {
fn from(_e: Utf8Error) -> RequestError {
RequestError::InvalidUtf8
}
}
impl From<GetCredentialsError> for RequestError {
fn from (e: GetCredentialsError) -> RequestError {
RequestError::NoCredentials(e)
}
}
impl From<ClientInfoError> for RequestError {
fn from(e: ClientInfoError) -> RequestError {
RequestError::ClientInfo(e)
}
}
impl Display for RequestError {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
use RequestError::*;
match self {
StreamIOError(e) => write!(f, "Stream IO error: {e}"),
InvalidUtf8 => write!(f, "Could not decode UTF-8 from bytestream"),
MalformedHttpRequest => write!(f, "Maformed HTTP request"),
RequestTooLarge => write!(f, "HTTP request too large"),
NoCredentials(GetCredentialsError::Locked) => write!(f, "Recieved go-ahead but app is locked"),
NoCredentials(GetCredentialsError::Empty) => write!(f, "Received go-ahead but no credentials are known"),
ClientInfo(ClientInfoError::PidNotFound) => write!(f, "Could not resolve PID of client process."),
ClientInfo(ClientInfoError::NetstatError(e)) => write!(f, "Error getting client socket details: {e}"),
}
}
#[error("Error accessing credentials: {0}")]
NoCredentials(#[from] GetCredentialsError),
#[error("Error getting client details: {0}")]
ClientInfo(#[from] ClientInfoError),
#[error("Error from Tauri: {0}")]
Tauri(#[from] tauri::Error),
#[error("No main application window found")]
NoMainWindow,
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum GetCredentialsError {
#[error("Credentials are currently locked")]
Locked,
#[error("No credentials are known")]
Empty,
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum GetSessionError {
#[error("Request completed successfully but no credentials were returned")]
EmptyResponse, // SDK returned successfully but credentials are None
#[error("Error response from AWS SDK: {0}")]
SdkError(#[from] AwsSdkError<GetSessionTokenError>),
#[error("Could not construt session: credentials are locked")]
CredentialsLocked,
#[error("Could not construct session: no credentials are known")]
CredentialsEmpty,
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum UnlockError {
#[error("App is not locked")]
NotLocked,
#[error("No saved credentials were found")]
NoCredentials,
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
DbError(SqlxError),
#[error("Database error: {0}")]
DbError(#[from] SqlxError),
#[error("Failed to create AWS session: {0}")]
GetSession(#[from] GetSessionError),
}
impl From<SqlxError> for UnlockError {
fn from (e: SqlxError) -> UnlockError {
match e {
SqlxError::RowNotFound => UnlockError::NoCredentials,
_ => UnlockError::DbError(e),
}
}
}
impl Display for UnlockError {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
use UnlockError::*;
match self {
NotLocked => write!(f, "App is not locked"),
NoCredentials => write!(f, "No saved credentials were found"),
BadPassphrase => write!(f, "Invalid passphrase"),
InvalidUtf8 => write!(f, "Decrypted data was corrupted"),
DbError(e) => write!(f, "Database error: {e}"),
}
}
#[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 {
PidNotFound,
NetstatError(netstat2::error::Error),
#[error("Found PID for client socket, but no corresponding process")]
ProcessNotFound,
#[error("Couldn't get client socket details: {0}")]
NetstatError(#[from] netstat2::error::Error),
}
impl From<netstat2::error::Error> for ClientInfoError {
fn from(e: netstat2::error::Error) -> ClientInfoError {
ClientInfoError::NetstatError(e)
// 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),
}
// 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)
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum CliError {
#[error(transparent)]
Request(#[from] RequestError),
#[error(transparent)]
Exec(#[from] ExecError),
}
// =========================
// Serialize implementations
// =========================
struct SerializeWrapper<E>(pub E);
impl Serialize for SerializeWrapper<&GetSessionTokenError> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let err = self.0;
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("code", &err.code())?;
map.serialize_entry("msg", &err.message())?;
map.serialize_entry("source", &None::<&str>)?;
map.end()
}
}
impl_serialize_basic!(SetupError);
impl_serialize_basic!(GetCredentialsError);
impl_serialize_basic!(ClientInfoError);
impl Serialize for HandlerError {
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 {
HandlerError::NoCredentials(src) => map.serialize_entry("source", &src)?,
HandlerError::ClientInfo(src) => map.serialize_entry("source", &src)?,
_ => serialize_upstream_err(self, &mut map)?,
}
map.end()
}
}
impl Serialize for SendResponseError {
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 {
SendResponseError::SessionRenew(src) => map.serialize_entry("source", &src)?,
_ => serialize_upstream_err(self, &mut map)?,
}
map.end()
}
}
impl Serialize for GetSessionError {
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 {
GetSessionError::SdkError(AwsSdkError::ServiceError(se_wrapper)) => {
let err = se_wrapper.err();
map.serialize_entry("source", &SerializeWrapper(err))?
}
_ => serialize_upstream_err(self, &mut map)?,
}
map.end()
}
}
impl Serialize for UnlockError {
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 {
UnlockError::GetSession(src) => map.serialize_entry("source", &src)?,
_ => serialize_upstream_err(self, &mut map)?,
}
map.end()
}
}

View File

@ -1,31 +1,29 @@
use serde::{Serialize, Deserialize};
use tauri::State;
use crate::state::{AppState, Session, Credentials};
use crate::config::AppConfig;
use crate::credentials::{Session,BaseCredentials};
use crate::errors::*;
use crate::clientinfo::Client;
use crate::state::AppState;
#[derive(Clone, Serialize, Deserialize)]
pub struct Client {
pub pid: u32,
pub exe: String,
}
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Request {
pub id: u64,
pub clients: Vec<Client>,
pub clients: Vec<Option<Client>>,
pub base: bool,
}
#[derive(Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct RequestResponse {
pub id: u64,
pub approval: Approval,
}
#[derive(Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub enum Approval {
Approved,
Denied,
@ -33,38 +31,50 @@ pub enum Approval {
#[tauri::command]
pub fn respond(response: RequestResponse, app_state: State<'_, AppState>) -> Result<(), String> {
app_state.send_response(response)
.map_err(|e| format!("Error responding to request: {e}"))
pub async fn respond(response: RequestResponse, app_state: State<'_, AppState>) -> Result<(), SendResponseError> {
app_state.send_response(response).await
}
#[tauri::command]
pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Result<(), String> {
app_state.decrypt(&passphrase)
.await
.map_err(|e| e.to_string())
pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Result<(), UnlockError> {
app_state.unlock(&passphrase).await
}
#[tauri::command]
pub fn get_session_status(app_state: State<'_, AppState>) -> String {
let session = app_state.session.read().unwrap();
match *session {
pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String, ()> {
let session = app_state.session.read().await;
let status = match *session {
Session::Locked(_) => "locked".into(),
Session::Unlocked(_) => "unlocked".into(),
Session::Unlocked{..} => "unlocked".into(),
Session::Empty => "empty".into()
}
};
Ok(status)
}
#[tauri::command]
pub async fn save_credentials(
credentials: Credentials,
credentials: BaseCredentials,
passphrase: String,
app_state: State<'_, AppState>
) -> Result<(), String> {
app_state.save_creds(credentials, &passphrase)
.await
.map_err(|e| e.to_string())
) -> Result<(), UnlockError> {
app_state.new_creds(credentials, &passphrase).await
}
#[tauri::command]
pub async fn get_config(app_state: State<'_, AppState>) -> Result<AppConfig, ()> {
let config = app_state.config.read().await;
Ok(config.clone())
}
#[tauri::command]
pub async fn save_config(config: AppConfig, app_state: State<'_, AppState>) -> Result<(), String> {
app_state.update_config(config)
.await
.map_err(|e| format!("Error saving config: {e}"))?;
Ok(())
}

View File

@ -3,35 +3,34 @@
windows_subsystem = "windows"
)]
use std::str::FromStr;
mod app;
mod cli;
mod config;
mod credentials;
mod errors;
mod clientinfo;
mod ipc;
mod state;
mod server;
mod storage;
mod tray;
use crate::errors::ErrorPopup;
fn main() {
let initial_state = match state::AppState::new() {
Ok(state) => state,
Err(e) => {eprintln!("{}", e); return;}
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!(),
};
tauri::Builder::default()
.manage(initial_state)
.invoke_handler(tauri::generate_handler![
ipc::unlock,
ipc::respond,
ipc::get_session_status,
ipc::save_credentials,
])
.setup(|app| {
let addr = std::net::SocketAddrV4::from_str("127.0.0.1:12345").unwrap();
tauri::async_runtime::spawn(server::serve(addr, app.handle()));
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
if let Err(e) = res {
eprintln!("Error: {e}");
}
}

View File

@ -1,97 +1,243 @@
use core::time::Duration;
use std::io;
use std::net::SocketAddrV4;
use tokio::net::{TcpListener, TcpStream};
use std::net::{
Ipv4Addr,
SocketAddr,
SocketAddrV4,
};
use tokio::net::{
TcpListener,
TcpStream,
};
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 crate::clientinfo;
use crate::errors::RequestError;
use crate::{clientinfo, clientinfo::Client};
use crate::errors::*;
use crate::ipc::{Request, Approval};
use crate::state::AppState;
pub async fn serve(addr: SocketAddrV4, app_handle: AppHandle) -> io::Result<()> {
let listener = TcpListener::bind(&addr).await?;
println!("Listening on {addr}");
loop {
let new_handle = app_handle.app_handle();
match listener.accept().await {
Ok((stream, _)) => {
tokio::spawn(async {
if let Err(e) = handle(stream, new_handle).await {
eprintln!("{e}");
}
});
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?;
},
Err(e) => {
eprintln!("Error accepting connection: {e}");
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(Debug)]
pub struct Server {
addr: Ipv4Addr,
port: u16,
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 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;
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) {
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}");
}
}
}
}
}
// it doesn't really return Approval, we just need to placate the compiler
async fn stall(stream: &mut TcpStream) -> Result<Approval, tokio::io::Error> {
let delay = std::time::Duration::from_secs(1);
loop {
tokio::time::sleep(delay).await;
stream.write(b"x").await?;
}
}
async fn handle(mut stream: TcpStream, app_handle: AppHandle) -> Result<(), RequestError> {
let (chan_send, chan_recv) = oneshot::channel();
let app_state = app_handle.state::<crate::state::AppState>();
let request_id = app_state.register_request(chan_send);
let peer_addr = match stream.peer_addr()? {
std::net::SocketAddr::V4(addr) => addr,
_ => unreachable!(), // we only listen on IPv4
};
let clients = clientinfo::get_clients(peer_addr.port())?;
// Do we want to panic if this fails? Does that mean the frontend is dead?
let req = Request {id: request_id, clients};
app_handle.emit_all("credentials-request", req).unwrap();
let mut buf = [0; 8192]; // it's what tokio's BufReader uses
let mut n = 0;
loop {
n += stream.read(&mut buf[n..]).await?;
if &buf[(n - 4)..n] == b"\r\n\r\n" {break;}
if n == buf.len() {return Err(RequestError::RequestTooLarge);}
}
println!("{}", std::str::from_utf8(&buf).unwrap());
stream.write(b"HTTP/1.0 200 OK\r\n").await?;
stream.write(b"Content-Type: application/json\r\n").await?;
stream.write(b"X-Creddy-delaying-tactic: ").await?;
let approval = tokio::select!{
e = stall(&mut stream) => e?, // this will never return Ok, just Err if it can't write to the stream
r = chan_recv => r.unwrap(), // only panics if the sender is dropped without sending, which shouldn't happen
};
if matches!(approval, Approval::Denied) {
// because we own the stream, it gets closed when we return.
// Unfortunately we've already signaled 200 OK, there's no way around this -
// we have to write the status code first thing, and we have to assume that the user
// might need more time than that gives us (especially if entering the passphrase).
// Fortunately most AWS libs automatically retry if the request dies uncompleted, allowing
// us to respond with a proper error status.
return Ok(());
}
let creds = app_state.get_creds_serialized()?;
stream.write(b"\r\nContent-Length: ").await?;
stream.write(creds.as_bytes().len().to_string().as_bytes()).await?;
stream.write(b"\r\n\r\n").await?;
stream.write(creds.as_bytes()).await?;
stream.write(b"\r\n\r\n").await?;
Ok(())
}

View File

@ -1,157 +1,108 @@
use std::collections::HashMap;
use std::sync::RwLock;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use serde::{Serialize, Deserialize};
use tokio::sync::oneshot::Sender;
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions, sqlite::SqliteConnectOptions};
use sodiumoxide::crypto::{
pwhash,
pwhash::Salt,
secretbox,
secretbox::{Nonce, Key}
use tokio::{
sync::oneshot::Sender,
sync::RwLock,
time::sleep,
};
use sqlx::SqlitePool;
use tauri::async_runtime as runtime;
use tauri::Manager;
use crate::ipc;
use crate::app::APP;
use crate::credentials::{
Session,
BaseCredentials,
SessionCredentials,
};
use crate::{config, config::AppConfig};
use crate::ipc::{self, Approval};
use crate::clientinfo::Client;
use crate::errors::*;
use crate::server::Server;
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
pub enum Credentials {
#[serde(rename_all = "PascalCase")]
LongLived {
access_key_id: String,
secret_access_key: String,
},
#[serde(rename_all = "PascalCase")]
ShortLived {
access_key_id: String,
secret_access_key: String,
token: String,
expiration: String,
},
}
pub struct LockedCredentials {
access_key_id: String,
secret_key_enc: Vec<u8>,
salt: Salt,
nonce: Nonce,
}
pub enum Session {
Unlocked(Credentials),
Locked(LockedCredentials),
Empty,
}
// #[derive(Serialize, Deserialize)]
// pub enum SessionStatus {
// Unlocked,
// Locked,
// Empty,
// }
#[derive(Debug)]
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>>>,
pool: SqlitePool,
pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
server: RwLock<Server>,
pool: sqlx::SqlitePool,
}
impl AppState {
pub fn new() -> Result<Self, SetupError> {
let conn_opts = SqliteConnectOptions::new()
.filename("creddy.db")
.create_if_missing(true);
let pool_opts = SqlitePoolOptions::new();
let pool: SqlitePool = runtime::block_on(pool_opts.connect_with(conn_opts))?;
runtime::block_on(sqlx::migrate!().run(&pool))?;
let creds = runtime::block_on(Self::load_creds(&pool))?;
let state = AppState {
session: RwLock::new(creds),
pub fn new(config: AppConfig, session: Session, server: Server, pool: SqlitePool) -> 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),
pool,
};
Ok(state)
}
}
async fn load_creds(pool: &SqlitePool) -> Result<Session, SetupError> {
let res = sqlx::query!("SELECT * FROM credentials")
.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: Credentials, passphrase: &str) -> Result<(), sqlx::error::Error> {
let (key_id, secret_key) = match creds {
Credentials::LongLived {access_key_id, secret_access_key} => {
(access_key_id, secret_access_key)
},
_ => unreachable!(),
};
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 key_enc = secretbox::seal(secret_key.as_bytes(), &nonce, &key);
// insert into database
// eventually replace this with a temporary session
let mut session = self.session.write().unwrap();
*session = Session::Unlocked(Credentials::LongLived {
access_key_id: key_id,
secret_access_key: secret_key,
});
pub async fn new_creds(&self, base_creds: BaseCredentials, passphrase: &str) -> Result<(), UnlockError> {
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?;
Ok(())
}
pub fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
let mut live_config = self.config.write().await;
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
{
let mut sv = self.server.write().await;
sv.rebind(new_config.listen_addr, new_config.listen_port).await?;
}
new_config.save(&self.pool).await?;
*live_config = new_config;
Ok(())
}
pub async fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
let count = {
let mut c = self.request_count.write().unwrap();
let mut c = self.request_count.write().await;
*c += 1;
c
};
let mut open_requests = self.open_requests.write().unwrap();
let mut open_requests = self.open_requests.write().await;
open_requests.insert(*count, chan); // `count` is the request id
*count
}
pub fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
let mut open_requests = self.open_requests.write().unwrap();
pub async fn unregister_request(&self, id: u64) {
let mut open_requests = self.open_requests.write().await;
open_requests.remove(&id);
}
pub async fn req_count(&self) -> usize {
let open_requests = self.open_requests.read().await;
open_requests.len()
}
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
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 chan = open_requests
.remove(&response.id)
.ok_or(SendResponseError::NotFound)
@ -161,36 +112,57 @@ impl AppState {
.map_err(|_e| SendResponseError::Abandoned)
}
pub async fn decrypt(&self, passphrase: &str) -> Result<(), UnlockError> {
let session = self.session.read().unwrap();
let locked = match *session {
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)
}
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
let base_creds = match *self.session.read().await {
Session::Empty => {return Err(UnlockError::NoCredentials);},
Session::Unlocked(_) => {return Err(UnlockError::NotLocked);},
Session::Locked(ref c) => c,
Session::Unlocked{..} => {return Err(UnlockError::NotLocked);},
Session::Locked(ref locked) => locked.decrypt(passphrase)?,
};
// Read lock is dropped here, so this doesn't deadlock
self.new_session(base_creds).await?;
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(), &locked.salt).unwrap();
let decrypted = secretbox::open(&locked.secret_key_enc, &locked.nonce, &Key(key_buf))
.map_err(|_e| UnlockError::BadPassphrase)?;
let secret_str = String::from_utf8(decrypted).map_err(|_e| UnlockError::InvalidUtf8)?;
let mut session = self.session.write().unwrap();
let creds = Credentials::LongLived {
access_key_id: locked.access_key_id.clone(),
secret_access_key: secret_str,
};
*session = Session::Unlocked(creds);
Ok(())
}
pub fn get_creds_serialized(&self) -> Result<String, GetCredentialsError> {
let session = self.session.read().unwrap();
pub async fn serialize_base_creds(&self) -> Result<String, GetCredentialsError> {
let session = self.session.read().await;
match *session {
Session::Unlocked(ref creds) => Ok(serde_json::to_string(creds).unwrap()),
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_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),
}
}
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(())
}
}

View File

@ -1,42 +0,0 @@
use sodiumoxide::crypto::{pwhash, secretbox};
use crate::state;
pub fn save(data: &str, passphrase: &str) {
let salt = pwhash::Salt([0; 32]); // yes yes, just for now
let mut kbuf = [0; secretbox::KEYBYTES];
pwhash::derive_key_interactive(&mut kbuf, passphrase.as_bytes(), &salt)
.expect("Couldn't compute password hash. Are you out of memory?");
let key = secretbox::Key(kbuf);
let nonce = secretbox::Nonce([0; 24]); // we don't care about e.g. replay attacks so this might be safe?
let encrypted = secretbox::seal(data.as_bytes(), &nonce, &key);
//todo: store in a database, along with salt, nonce, and hash parameters
std::fs::write("credentials.enc", &encrypted).expect("Failed to write file.");
//todo: key is automatically zeroed, but we should use 'zeroize' or something to zero out passphrase and data
}
// pub fn load(passphrase: &str) -> String {
// let salt = pwhash::Salt([0; 32]);
// let mut kbuf = [0; secretbox::KEYBYTES];
// pwhash::derive_key_interactive(&mut kbuf, passphrase.as_bytes(), &salt)
// .expect("Couldn't compute password hash. Are you out of memory?");
// let key = secretbox::Key(kbuf);
// let nonce = secretbox::Nonce([0; 24]);
// let encrypted = std::fs::read("credentials.enc").expect("Failed to read file.");
// let decrypted = secretbox::open(&encrypted, &nonce, &key).expect("Failed to decrypt.");
// String::from_utf8(decrypted).expect("Invalid utf-8")
// }
pub fn load(passphrase: &str) -> state::Credentials {
state::Credentials::ShortLived {
access_key_id: "ASIAZ7WSVLORKQI27QGB".to_string(),
secret_access_key: "blah".to_string(),
token: "gah".to_string(),
expiration: "2022-11-29T10:45:12Z".to_string(),
}
}

36
src-tauri/src/tray.rs Normal file
View File

@ -0,0 +1,36 @@
use tauri::{
AppHandle,
Manager,
SystemTray,
SystemTrayEvent,
SystemTrayMenu,
CustomMenuItem,
};
pub fn create() -> SystemTray {
let show = CustomMenuItem::new("show".to_string(), "Show");
let quit = CustomMenuItem::new("exit".to_string(), "Exit");
let menu = SystemTrayMenu::new()
.add_item(show)
.add_item(quit);
SystemTray::new().with_menu(menu)
}
pub fn handle_event(app: &AppHandle, event: SystemTrayEvent) {
match event {
SystemTrayEvent::MenuItemClick{ id, .. } => {
match id.as_str() {
"exit" => app.exit(0),
"show" => {
let _ = app.get_window("main").map(|w| w.show());
}
_ => (),
}
}
_ => (),
}
}

View File

@ -8,11 +8,11 @@
},
"package": {
"productName": "creddy",
"version": "0.1.0"
"version": "0.2.0"
},
"tauri": {
"allowlist": {
"all": true
"os": {"all": true}
},
"bundle": {
"active": true,
@ -29,7 +29,7 @@
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.tauri.dev",
"identifier": "creddy",
"longDescription": "",
"macOS": {
"entitlements": null,
@ -48,7 +48,10 @@
}
},
"security": {
"csp": null
"csp": {
"default-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"]
}
},
"updater": {
"active": false
@ -58,9 +61,15 @@
"fullscreen": false,
"height": 600,
"resizable": true,
"label": "main",
"title": "Creddy",
"width": 800
"width": 800,
"visible": false
}
]
],
"systemTray": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true
}
}
}

View File

@ -1,37 +1,23 @@
<script>
import { emit, listen } from '@tauri-apps/api/event';
import queue from './lib/queue.js';
const VIEWS = import.meta.glob('./views/*.svelte', {eager: true});
window.emit = emit;
window.queue = queue;
var appState = {
currentRequest: null,
pendingRequests: queue(),
credentialStatus: 'locked',
}
window.appState = appState;
import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
window.invoke = invoke;
import { appState, acceptRequest } from './lib/state.js';
import { views, currentView, navigate } from './lib/routing.js';
var currentView = VIEWS['./views/Home.svelte'].default;
window.currentView = currentView;
window.VIEWS = VIEWS;
function navigate(svelteEvent) {
const moduleName = `./views/${svelteEvent.detail.target}.svelte`;
currentView = VIEWS[moduleName].default;
}
window.navigate = navigate;
$views = import.meta.glob('./views/*.svelte', {eager: true});
navigate('Home');
invoke('get_config').then(config => $appState.config = config);
listen('credentials-request', (tauriEvent) => {
appState.pendingRequests.put(tauriEvent.payload);
console.log('Received request.');
$appState.pendingRequests.put(tauriEvent.payload);
});
acceptRequest();
</script>
<svelte:component this={currentView} on:navigate={navigate} bind:appState={appState} />
<svelte:component this="{$currentView}" />

153
src/assets/vault_door.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

8
src/lib/errors.js Normal file
View File

@ -0,0 +1,8 @@
export function getRootCause(error) {
if (error.source) {
return getRootCause(error.source);
}
else {
return error;
}
}

11
src/lib/routing.js Normal file
View File

@ -0,0 +1,11 @@
import { writable, get } from 'svelte/store';
export let views = writable();
export let currentView = writable();
export let previousView = writable();
export function navigate(viewName) {
let v = get(views)[`./views/${viewName}.svelte`].default;
currentView.set(v)
}

33
src/lib/state.js Normal file
View File

@ -0,0 +1,33 @@
import { writable, get } from 'svelte/store';
import queue from './queue.js';
import { navigate, currentView, previousView } from './routing.js';
export let appState = writable({
currentRequest: null,
pendingRequests: queue(),
credentialStatus: 'locked',
});
export async function acceptRequest() {
let req = await get(appState).pendingRequests.get();
appState.update($appState => {
$appState.currentRequest = req;
return $appState;
});
previousView.set(get(currentView));
navigate('Approve');
}
export function completeRequest() {
appState.update($appState => {
$appState.currentRequest = null;
return $appState;
});
currentView.set(get(previousView));
previousView.set(null);
acceptRequest();
}

View File

@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.btn-alert-error {
@apply bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content
}

9
src/ui/Button.svelte Normal file
View File

@ -0,0 +1,9 @@
<script>
import Icon from './Icon.svelte';
export let icon = null;
</script>
<button>
{#if icon}<Icon name={icon} class="w-4 text-gray-200" />{/if}
<slot></slot>
</button>

67
src/ui/ErrorAlert.svelte Normal file
View File

@ -0,0 +1,67 @@
<script>
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
let extraClasses = "";
export {extraClasses as class};
export let slideDuration = 150;
let animationClass = "";
export function shake() {
animationClass = 'shake';
window.setTimeout(() => animationClass = "", 400);
}
</script>
<style>
/* animation from https://svelte.dev/repl/e606c27c864045e5a9700691a7417f99?version=3.58.0 */
@keyframes shake {
0% {
transform: translateX(0px);
}
20% {
transform: translateX(10px);
}
40% {
transform: translateX(-10px);
}
60% {
transform: translateX(5px);
}
80% {
transform: translateX(-5px);
}
90% {
transform: translateX(2px);
}
95% {
transform: translateX(-2px);
}
100% {
transform: translateX(0px);
}
}
.shake {
animation-name: shake;
animation-play-state: running;
animation-duration: 0.4s;
}
</style>
<div in:slide="{{duration: slideDuration}}" class="alert alert-error shadow-lg {animationClass} {extraClasses}">
<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>
<slot></slot>
</span>
</div>
{#if $$slots.buttons}
<div>
<slot name="buttons"></slot>
</div>
{/if}
</div>

11
src/ui/Icon.svelte Normal file
View File

@ -0,0 +1,11 @@
<script>
const ICONS = import.meta.glob('./icons/*.svelte', {eager: true});
export let name;
let classes = "";
export {classes as class};
let svg = ICONS[`./icons/${name}.svelte`].default;
</script>
<svelte:component this={svg} class={classes} />

43
src/ui/Link.svelte Normal file
View File

@ -0,0 +1,43 @@
<script>
import { navigate } from '../lib/routing.js';
export let target;
export let hotkey = null;
export let ctrl = false
export let alt = false;
export let shift = false;
let classes = "";
export {classes as class};
function click() {
if (typeof target === 'string') {
navigate(target);
}
else if (typeof target === 'function') {
target();
}
else {
throw(`Link target is not a string or a function: ${target}`)
}
}
function handleHotkey(event) {
if (!hotkey) return;
if (ctrl && !event.ctrlKey) return;
if (alt && !event.altKey) return;
if (shift && !event.shiftKey) return;
if (event.key === hotkey) {
click();
}
}
</script>
<svelte:window on:keydown={handleHotkey} />
<a href="/{target}" on:click|preventDefault="{click}" class={classes}>
<slot></slot>
</a>

29
src/ui/Nav.svelte Normal file
View File

@ -0,0 +1,29 @@
<script>
import Link from './Link.svelte';
import Icon from './Icon.svelte';
export let position = "sticky";
</script>
<nav class="{position} top-0 bg-base-100 w-full flex justify-between items-center p-2">
<div>
<Link target="Home">
<button class="btn btn-square btn-ghost align-middle">
<Icon name="home" class="w-8 h-8 stroke-2" />
</button>
</Link>
</div>
{#if $$slots.title}
<slot name="title"></slot>
{/if}
<div>
<Link target="Settings">
<button class="btn btn-square btn-ghost align-middle ">
<Icon name="cog-8-tooth" class="w-8 h-8 stroke-2" />
</button>
</Link>
</div>
</nav>

113
src/ui/Spinner.svelte Normal file
View File

@ -0,0 +1,113 @@
<script>
export let color = 'base-content';
export let thickness = '2px';
let classes = '';
export { classes as class };
const colorVars = {
'primary': 'p',
'primary-focus': 'pf',
'primary-content': 'pc',
'secondary': 's',
'secondary-focus': 'sf',
'secondary-content': 'sc',
'accent': 'a',
'accent-focus': 'af',
'accent-content': 'ac',
'neutral': 'n',
'neutral-focus': 'nf',
'neutral-content': 'nc',
'base-100': 'b1',
'base-200': 'b2',
'base-300': 'b3',
'base-content': 'bc',
'info': 'in',
'info-content': 'inc',
'success': 'su',
'success-content': 'suc',
'warning': 'wa',
'warning-content': 'wac',
'error': 'er',
'error-content': 'erc',
}
let arcStyle = `border-width: ${thickness};`;
arcStyle += `border-color: hsl(var(--${colorVars[color]})) transparent transparent transparent;`;
</script>
<style>
#spinner {
position: relative;
animation: spin;
animation-duration: 1.5s;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
@keyframes spin {
50% { transform: rotate(225deg); }
100% { transform: rotate(360deg); }
}
.arc {
position: absolute;
top: 0;
left: 0;
border-radius: 9999px;
}
.arc-top {
transform: rotate(-45deg);
}
.arc-right {
animation: spin-right;
animation-duration: 3s;
animation-iteration-count: infinite;
}
.arc-bottom {
animation: spin-bottom;
animation-duration: 3s;
animation-iteration-count: infinite;
}
.arc-left {
animation: spin-left;
animation-duration: 3s;
animation-iteration-count: infinite;
}
@keyframes spin-top {
0% { transform: rotate(-45deg); }
50% { transform: rotate(315deg); }
100% { transform: rotate(-45deg); }
}
@keyframes spin-right {
0% { transform: rotate(45deg); }
50% { transform: rotate(315deg); }
100% { transform: rotate(405deg); }
}
@keyframes spin-bottom {
0% { transform: rotate(135deg); }
50% { transform: rotate(315deg); }
100% { transform: rotate(495deg); }
}
@keyframes spin-left {
0% { transform: rotate(225deg); }
50% { transform: rotate(315deg); }
100% { transform: rotate(585deg); }
}
</style>
<div id="spinner" class="w-6 h-6 {classes}">
<div class="arc arc-top w-full h-full" style={arcStyle}></div>
<div class="arc arc-right w-full h-full" style={arcStyle}></div>
<div class="arc arc-bottom w-full h-full" style={arcStyle}></div>
<div class="arc arc-left w-full h-full" style={arcStyle}></div>
</div>

View File

@ -0,0 +1,8 @@
<script>
let classes = "";
export {classes as class};
</script>
<svg class={classes} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>

View File

@ -0,0 +1,9 @@
<script>
let classes = "";
export {classes as class};
</script>
<svg class="w-6 h-6 {classes}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>

8
src/ui/icons/home.svelte Normal file
View File

@ -0,0 +1,8 @@
<script>
let classes = "";
export {classes as class};
</script>
<svg class="w-6 h-6 {classes}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>

View File

@ -0,0 +1,8 @@
<script>
let classes = "";
export {classes as class};
</script>
<svg class={classes} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>

View File

@ -0,0 +1,80 @@
<script>
import { createEventDispatcher } from 'svelte';
import Setting from './Setting.svelte';
export let title;
export let value;
export let unit = '';
export let min = null;
export let max = null;
export let decimal = false;
const dispatch = createEventDispatcher();
$: localValue = value.toString();
let lastInputTime = null;
function debounce(event) {
lastInputTime = Date.now();
localValue = localValue.replace(/[^-0-9.]/g, '');
const eventTime = lastInputTime;
const pendingValue = localValue;
window.setTimeout(
() => {
// if no other inputs have occured since then
if (eventTime === lastInputTime) {
updateValue(pendingValue);
}
},
500
)
}
let error = null;
function updateValue(newValue) {
// Don't update the value, but also don't error, if it's empty
// or if it could be the start of a negative or decimal number
if (newValue.match(/^$|^-$|^\.$/) !== null) {
error = null;
return;
}
const num = parseFloat(newValue);
if (num % 1 !== 0 && !decimal) {
error = `${num} is not a whole number`;
}
else if (min !== null && num < min) {
error = `Too low (minimum ${min})`;
}
else if (max !== null && num > max) {
error = `Too large (maximum ${max})`
}
else {
error = null;
value = num;
dispatch('update', {value})
}
}
</script>
<Setting {title}>
<div slot="input">
{#if unit}
<span class="mr-2">{unit}:</span>
{/if}
<div class="tooltip tooltip-error" class:tooltip-open={error !== null} data-tip="{error}">
<input
type="text"
class="input input-sm input-bordered text-right"
size="{Math.max(5, localValue.length)}"
class:input-error={error}
bind:value={localValue}
on:input="{debounce}"
/>
</div>
</div>
<slot name="description" slot="description"></slot>
</Setting>

View File

@ -0,0 +1,19 @@
<script>
import { slide } from 'svelte/transition';
import ErrorAlert from '../ErrorAlert.svelte';
export let title;
</script>
<div class="divider"></div>
<div class="flex justify-between">
<h3 class="text-lg font-bold">{title}</h3>
<slot name="input"></slot>
</div>
{#if $$slots.description}
<p class="mt-3">
<slot name="description"></slot>
</p>
{/if}

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}>
<input
slot="input"
type="checkbox"
class="toggle toggle-success"
bind:checked={value}
on:change={e => dispatch('update', {value: e.target.checked})}
/>
<slot name="description" slot="description"></slot>
</Setting>

3
src/ui/settings/index.js Normal file
View File

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

View File

@ -1,39 +1,126 @@
<script>
import { createEventDispatcher } from 'svelte';
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri';
export let appState;
import { navigate } from '../lib/routing.js';
import { appState, completeRequest } from '../lib/state.js';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte';
const dispatch = createEventDispatcher();
// Send response to backend, display error if applicable
let error, alert;
async function respond() {
let {id, approval} = $appState.currentRequest;
try {
await invoke('respond', {response: {id, approval}});
navigate('ShowResponse');
}
catch (e) {
if (error) {
alert.shake();
}
error = e;
}
}
// Approval has one of several outcomes depending on current credential state
async function approve() {
$appState.currentRequest.approval = 'Approved';
let status = await invoke('get_session_status');
if (status === 'unlocked') {
dispatch('navigate', {target: 'ShowApproved'});
await respond();
}
else if (status === 'locked') {
dispatch('navigate', {target: 'Unlock'})
navigate('Unlock');
}
else {
dispatch('navigate', {target: 'EnterCredentials'});
navigate('EnterCredentials');
}
}
function deny() {
dispatch('navigate', {target: 'ShowDenied'});
// Denial has only one
async function deny() {
$appState.currentRequest.approval = 'Denied';
await respond();
}
// 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];
}
// Executable paths can be long, so ensure they only break on \ or /
function breakPath(client) {
return client.exe.replace(/(\\|\/)/g, '$1<wbr>');
}
// if the request has already been approved/denied, send response immediately
onMount(async () => {
if ($appState.currentRequest.approval) {
await respond();
}
})
</script>
<h2 class="text-3xl text-gray-200">An application would like to access your AWS credentials.</h2>
<button on:click={approve}>
<svg class="w-32 stroke-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<!-- Don't render at all if we're just going to immediately proceed to the next screen -->
{#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">
{#if error}
<ErrorAlert bind:this={alert}>
{error}
<svelte:fragment slot="buttons">
<button class="btn btn-sm btn-alert-error" on:click={completeRequest}>Cancel</button>
<button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button>
</svelte:fragment>
</ErrorAlert>
{/if}
<button on:click={deny}>
<svg class="w-32 stroke-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
{#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">
<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>
</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>
</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>
</button>
</Link>
</div>
</div>
{/if}

View File

@ -1,41 +1,83 @@
<script>
import { onMount, createEventDispatcher } from 'svelte';
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri';
import { getRootCause } from '../lib/errors.js';
export let appState;
import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js';
import Link from '../ui/Link.svelte';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Spinner from '../ui/Spinner.svelte';
const dispatch = createEventDispatcher();
let AccessKeyId, SecretAccessKey, passphrase
let errorMsg = null;
let alert;
let AccessKeyId, SecretAccessKey, passphrase, confirmPassphrase
function confirm() {
if (passphrase !== confirmPassphrase) {
errorMsg = 'Passphrases do not match.'
}
}
let saving = false;
async function save() {
if (passphrase !== confirmPassphrase) {
alert.shake();
return;
}
let credentials = {AccessKeyId, SecretAccessKey};
try {
console.log('Saving credentials.');
let credentials = {AccessKeyId, SecretAccessKey};
console.log(credentials);
saving = true;
await invoke('save_credentials', {credentials, passphrase});
if (appState.currentRequest) {
dispatch('navigate', {target: 'ShowApproved'})
if ($appState.currentRequest) {
navigate('Approve');
}
else {
dispatch('navigate', {target: 'Home'})
navigate('Home');
}
}
catch (e) {
console.log("Error saving credentials:", e);
if (e.code === "GetSession") {
let root = getRootCause(e);
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
}
else {
errorMsg = e.msg;
}
if (alert) {
alert.shake();
}
saving = false;
}
}
</script>
<form action="#" on:submit|preventDefault="{save}">
<div class="text-gray-200">AWS Access Key ID</div>
<input class="text-gray-200 bg-zinc-800" type="text" bind:value="{AccessKeyId}" />
<div class="text-gray-200">AWS Secret Access Key</div>
<input class="text-gray-200 bg-zinc-800" type="text" bind:value="{SecretAccessKey}" />
<form action="#" on:submit|preventDefault="{save}" class="form-control space-y-4 max-w-sm m-auto p-4 h-screen justify-center">
<h2 class="text-2xl font-bold text-center">Enter your credentials</h2>
<div class="text-gray-200">Passphrase</div>
<input class="text-gray-200 bg-zinc-800" type="text" bind:value="{passphrase}" />
{#if errorMsg}
<ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert>
{/if}
<input class="text-gray-200" type="submit" />
<input type="text" placeholder="AWS Access Key ID" bind:value="{AccessKeyId}" class="input input-bordered" />
<input type="password" placeholder="AWS Secret Access Key" bind:value="{SecretAccessKey}" 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} />
<button type="submit" class="btn btn-primary">
{#if saving}
<Spinner class="w-5 h-5" color="primary-content" thickness="2px"/>
{:else}
Submit
{/if}
</button>
<Link target="Home" hotkey="Escape">
<button class="btn btn-sm btn-outline w-full">Cancel</button>
</Link>
</form>

View File

@ -1,18 +1,49 @@
<script>
import { onMount, createEventDispatcher } from 'svelte';
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri';
export let appState;
import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js';
import Nav from '../ui/Nav.svelte';
import Icon from '../ui/Icon.svelte';
import Link from '../ui/Link.svelte';
const dispatch = createEventDispatcher();
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;
console.log('Got credentials request from queue:');
console.log(req);
dispatch('navigate', {target: 'Approve'});
});
// onMount(async () => {
// // will block until a request comes in
// let req = await $appState.pendingRequests.get();
// $appState.currentRequest = req;
// navigate('Approve');
// });
</script>
<h1 class="text-4xl text-gray-300">Creddy</h1>
<Nav position="fixed">
<h2 slot="title" class="text-3xl font-bold">Creddy</h2>
</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'}
{@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>
{:else if status === 'unlocked'}
{@html vaultDoorSvg}
<h2 class="text-2xl font-bold">Waiting for requests</h2>
{: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>

94
src/views/Settings.svelte Normal file
View File

@ -0,0 +1,94 @@
<script>
import { invoke } from '@tauri-apps/api/tauri';
import { type } from '@tauri-apps/api/os';
import { appState } from '../lib/state.js';
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 { fly } from 'svelte/transition';
import { backInOut } from 'svelte/easing';
let error = null;
async function save() {
try {
await invoke('save_config', {config: $appState.config});
}
catch (e) {
error = e;
$appState.config = await invoke('get_config');
}
}
let osType = '';
type().then(t => osType = t);
</script>
<Nav>
<h2 slot="title" class="text-2xl font-bold">Settings</h2>
</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> -->
<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>
<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="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>
<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}
{#if error}
<div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast">
<div class="alert alert-error no-animation">
<div>
<span>{error}</span>
</div>
<div>
<button class="btn btn-sm btn-alert-error" on:click={() => error = null}>Ok</button>
</div>
</div>
</div>
{/if}

View File

@ -1,21 +0,0 @@
<script>
import { onMount, createEventDispatcher } from 'svelte';
import { emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
export let appState;
onMount(async () => {
let response = {
id: appState.currentRequest.id,
approval: 'Approved',
}
await invoke('respond', {response});
appState.currentRequest = null;
});
const dispatch = createEventDispatcher();
window.setTimeout(() => dispatch('navigate', {target: 'Home'}), 3000);
</script>
<h1 class="text-4xl text-gray-300">Approved!</h1>

View File

@ -1,21 +0,0 @@
<script>
import { onMount, createEventDispatcher } from 'svelte';
import { emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
export let appState;
onMount(async () => {
let response = {
id: appState.currentRequest.id,
approval: 'Denied',
}
await invoke('respond', {response});
appState.currentRequest = null;
});
const dispatch = createEventDispatcher();
window.setTimeout(() => dispatch('navigate', {target: 'Home'}), 3000);
</script>
<h1 class="text-4xl text-gray-300">Denied!</h1>

View File

@ -0,0 +1,38 @@
<script>
import { onMount } from 'svelte';
import { draw, fade } from 'svelte/transition';
import { appState, completeRequest } from '../lib/state.js';
let success = false;
let error = null;
let drawDuration = $appState.config.rehide_ms >= 750 ? 500 : 0;
let fadeDuration = drawDuration * 0.6;
let fadeDelay = drawDuration * 0.4;
onMount(() => {
window.setTimeout(
completeRequest,
// Extra 50ms so the window can finish disappearing before the redraw
Math.min(5000, $appState.config.rehide_ms + 50),
)
})
</script>
<div class="flex flex-col h-screen items-center justify-center max-w-max m-auto">
{#if $appState.currentRequest.approval === 'Approved'}
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path in:draw="{{duration: drawDuration}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path in:draw="{{duration: 500}}" stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{/if}
<div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold">
{$appState.currentRequest.approval}!
</div>
</div>

View File

@ -1,25 +1,81 @@
<script>
import { invoke } from '@tauri-apps/api/tauri';
import { createEventDispatcher } from 'svelte';
import { onMount } from 'svelte';
export let appState;
import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js';
import { getRootCause } from '../lib/errors.js';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte';
import Spinner from '../ui/Spinner.svelte';
const dispatch = createEventDispatcher();
let errorMsg = null;
let alert;
let passphrase = '';
let loadTime = 0;
let saving = false;
async function unlock() {
console.log('invoking unlock command.')
// The hotkey for navigating here from homepage is Enter, which also
// happens to trigger the form submit event
if (Date.now() - loadTime < 10) {
return;
}
try {
await invoke('unlock', {passphrase});
saving = true;
let r = await invoke('unlock', {passphrase});
$appState.credentialStatus = 'unlocked';
if ($appState.currentRequest) {
navigate('Approve');
}
else {
navigate('Home');
}
}
catch (e) {
console.log('Unlock error:', e);
window.error = e;
if (e.code === 'GetSession') {
let root = getRootCause(e);
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
}
else {
errorMsg = e.msg;
}
if (alert) {
alert.shake();
}
saving = true;
}
}
onMount(() => {
loadTime = Date.now();
})
</script>
<form action="#" on:submit|preventDefault="{unlock}">
<div class="text-gray-200">Enter your passphrase:</div>
<input class="text-gray-200 bg-zinc-800" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" />
<form action="#" on:submit|preventDefault="{unlock}" class="form-control space-y-4 max-w-sm m-auto p-4 h-screen justify-center">
<h2 class="font-bold text-2xl text-center">Enter your passphrase</h2>
{#if errorMsg}
<ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert>
{/if}
<!-- svelte-ignore a11y-autofocus -->
<input autofocus name="password" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" class="input input-bordered" />
<button type="submit" class="btn btn-primary">
{#if saving}
<Spinner class="w-5 h-5" color="primary-content" thickness="2px"/>
{:else}
Submit
{/if}
</button>
<Link target="Home" hotkey="Escape">
<button class="btn btn-outline btn-sm w-full">Cancel</button>
</Link>
</form>

View File

@ -7,5 +7,7 @@ module.exports = {
theme: {
extend: {},
},
plugins: [],
plugins: [
require('daisyui'),
],
}