Compare commits
46 Commits
dev
...
33a5600a30
Author | SHA1 | Date | |
---|---|---|---|
33a5600a30 | |||
741169d807 | |||
ebc00a5df6 | |||
c2cc007a81 | |||
4aab08e6f0 | |||
12d9d733a5 | |||
35271049dd | |||
6f9cd6b471 | |||
865b7fd5c4 | |||
f35352eedd | |||
53580d7919 | |||
049b81610d | |||
fd60899f16 | |||
e0c4c849dc | |||
cb26201506 | |||
992e3c8db2 | |||
4956b64371 | |||
df6b362a31 | |||
2943634248 | |||
06f5a1af42 | |||
61d674199f | |||
398916fe10 | |||
bf4c46238e | |||
5ffa55c03c | |||
50f0985f4f | |||
69475604c0 | |||
856b6f1e1b | |||
414379b74e | |||
80b92ebe69 | |||
983d0e8639 | |||
d77437cda8 | |||
3d5cbedae1 | |||
10fd1d6028 | |||
67705aa2d1 | |||
9055fa41aa | |||
48269855e5 | |||
1e4e1c9a5f | |||
196510e9a2 | |||
e423df8e51 | |||
2cfde4d841 | |||
7d462645b4 | |||
8c271281f7 | |||
234d9e0471 | |||
397928b8f1 | |||
c19b573b26 | |||
cee43342b9 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,8 +1,7 @@
|
||||
dist
|
||||
**/node_modules
|
||||
src-tauri/target/
|
||||
**/creddy.db
|
||||
|
||||
# just in case
|
||||
credentials*
|
||||
|
||||
|
||||
|
25
README.md
25
README.md
@ -1 +1,24 @@
|
||||
## Creddy: Low-friction AWS credential manager
|
||||
## Creddy: Low-friction AWS credential helper
|
||||
|
||||
_Security at the expense of usability comes at the expense of security._ - Avi Douglen
|
||||
|
||||
**Creddy** is an AWS credential helper that focuses on improving security without interrupting your workflow (much). It works by mimicking the AWS Instance Metadata Service and requesting your approval before granting any application access to your AWS credentials. Additionally, the credentials it hands out are short-lived session credentials rather than long-lived credentials, meaning that even if they are compromised, the damage that the attacker can do is limited.
|
||||
|
||||
### What was wrong with all the existing AWS credential managers?
|
||||
|
||||
Most other AWS credential managers that I have seen differ in two ways.
|
||||
|
||||
**First**, they require the user to be _proactive_ instead of _reactive_, i.e. you must remember "this command will require AWS credentials" and invoke it in some special way. By contrast, Creddy waits patiently in the background until an application requests credentials, then asks for your approval before proceeding. In most cases, this requires only a couple of keystrokes, after which your original operation continues as invoked. This completely prevents the frustrating workflow of:
|
||||
|
||||
```
|
||||
$ aws do-something-interesting
|
||||
...
|
||||
...
|
||||
Unable to locate credentials. You can configure credentials by running "aws configure".
|
||||
# a deep sigh of the most profound resignation
|
||||
$ with-aws-credentials aws do-something-interesting
|
||||
```
|
||||
|
||||
**Second**, other credential managers are mostly backed by the system credential store. While this may sound like a good idea, it has a critical weakness: By default, on most systems, a user's credentials are accessible to _any process running as that user_. In other words, if your quick nodejs script happens to depend on a compromised module, congratulations: you have just given that module access to your AWS account.
|
||||
|
||||
By contrast, Creddy encrypts your main long-lived AWS credentials with a passphrase (using libsodium's `SecretBox`) and, importantly, _does not store that passphrase_. Although this means that you, the user, must re-enter the passphrase every time Creddy needs to generate a new session, this is normally only necessary about once per day. In my own opinion, this is a worthwhile tradeoff.
|
||||
|
48
doc/security.md
Normal file
48
doc/security.md
Normal file
@ -0,0 +1,48 @@
|
||||
## Security considerations
|
||||
|
||||
The following is a list of security features that I hope to add eventually, in approximately the order in which I expect to add them.
|
||||
|
||||
* Request logging, obviously.
|
||||
* Disallow all Tauri APIs except for `invoke` and `emit`. The sole job of the frontend should be to collect user interaction. Everything else should be mediated through the backend.
|
||||
* Maximally-restrictive CSP - not sure if Tauri does this by default. Also not sure whether it will interfere with IPC to set a zero-access CSP.
|
||||
* Allow user to specify a role to assume, so that role can be given narrower permissions. Allow falling back to the root credentials in the event that broader permissions are required. (Unsure about this one, is there a good way to make it low-friction?)
|
||||
* To defend against the possibility that an attacker could replace, say, the `aws` executable with a malicious one that snarfs your credentials and then passes the command on to the real one, maybe track the path (and maybe even the hash) of the executable, and raise a warning if this is the first time we've seen that one? Using the hash would be safer, but would also introduce a lot of false positives, since every time the application gets updated it would trigger. On the other hand, users should presumably know when they've updated things, so maybe it would be ok. On the _other_ other hand, if somebody doesn't use `aws` very often then it might be weeks or months in between updating it and actually using the updated executable, in which case they probably won't remember that this is the first time they've used it since updating.
|
||||
Another possible approach is to _watch_ the files in question, and alert the user whenever any of them changes. Presumably the user will know whether this change is expected or not.
|
||||
* Downgrade privileges after launching. In particular, if possible, disallow any kind of outgoing network access (obviously we have to bind the listening socket, but maybe we can filter that down to _just_ the ability to bind that particular address/port) and filesystem access outside of state db. I think this is doable on Linux, although it may involve high levels of `seccomp` grossness. No idea whether it's possible on Windows. Probably possible on MacOS although it may require lengths to which I am currently unwilling to go (e.g. pay for a certificate from Apple or something.)
|
||||
* "Panic button" - if a potential attack is detected (e.g. the user denies a request but Creddy discovers the request has already succeeded somehow), offer a one-click option to lock out the current IAM user. (Sadly, you can't revoke session tokens, so this is the only way to limit a potential compromise). Not sure how feasible this is, session credentials may be limited with regard to what kind of IAM operations they can carry out.)
|
||||
* Some kind of Yubikey or other HST integration. (Optional, since not everyone will have a HST.) This comes in two flavors:
|
||||
1. (Probably doable) Store the encryption key for the passphrase on the HST, and ask the HST to decrypt the passphrase instead of asking the user to enter it. This has the advantage of being a) lower-friction, since the user doesn't have to type in the passphrase, and b) more secure, since the application code never sees the encryption key.
|
||||
2. (Less doable) Store the actual AWS secret key on the HST, and then ask the HST to just sign the whole `GetSessionToken` request. This requires that the HST support the exact signing algorithm required by AWS, which a) it probably doesn't, and b) is subject to change anyway. So this is probably not doable, but it's worth at least double-checking, since it would provide the maximum theoretical level of security. (That is, after initial setup, the application would never again see the long-lived AWS secret key.)
|
||||
|
||||
|
||||
## Threat model
|
||||
|
||||
Who exactly are we defending against and why?
|
||||
|
||||
The basic idea behind Creddy is that it provides "gap coverage" between two wildly different security boundaries: 1) the older, user-based model, where all code executing as a given user is assumed to have the same level of trust, and 2) the newer, application-based model (most clearly seen on mobile devices) where that bondary instead exists around each _application_.
|
||||
|
||||
The unfortunate reality is that desktop environments are unlikely to adopt the latter model any time soon, if ever. This is primarily due to friction: Per-application security is a nightmare to manage. The only reason it works at all on mobile devices is because most mobile apps eschew the local device in favor of cloud-backed services where they can, e.g. for file storage. Arguably, the higher-friction trust model of mobile environments is in part _why_ mobile apps tend to be cloud-first.
|
||||
|
||||
Regardless, we live in a world where it's difficult to run untrusted code without giving it an inordinate level of access to the machine on which it runs. Creddy attempts to prevent that access from including your AWS credentials. The threat model is thus "untrusted code running under your user". This is especially likely to occur in the form of a supply-chain attack, where the compromised code is not your own but rather a dependency, or a dependency of a dependency, etc.
|
||||
|
||||
## Particular attacks
|
||||
|
||||
There are lots of ways that I can imagine someone might try to circumvent Creddy's protection. Most of them require that the attacker be targeting Creddy in particular, rather than just "AWS credentials generally". In addition, most of them are "noisy" - that is, there's a good chance that the attack will alert the user to the fact that they are being attacked. This is generally something attackers try to avoid, since an easily-detected attack is likely to be shut down before it can spread very far.
|
||||
|
||||
### Tricking Creddy into allowing a request that it shouldn't
|
||||
|
||||
If an attacker is able to compromise Creddy's frontend, e.g. via a JS library that Creddy relies on, they could forge "request accepted" responses and cause the backend to hand out credentials to an unauthorized client. Most likely, the user would immediately be alerted to the fact that Something Is Up because as soon as the request came in, Creddy would pop up requesting permission. When the user (presumably) denied the request, Creddy would discover that the request had already been approved - we could make this a high-alert situation because it would be unlikely to happen unless something fishy were going on. Additionally, the request and (hopefully) what executable made it would be logged.
|
||||
|
||||
### Tricking the user into allowing a request they didn't intend to
|
||||
|
||||
If an attacker can edit the user's .bashrc or similar, they could theoretically insert a function or pre-command hook that wraps, say, the `aws` command, and dump the credentials before continuing on with the user's command. This would most likely alert the user because either a) the attacker is hijacking the original `aws` command and thus it doesn't do what the user told it to, or b) the user's original `aws` command proceeds as normal after the malicious one, and the user is alerted by the second request where there should only have been one.
|
||||
|
||||
A similar but more-difficult-to-detect attack would be replacing the `aws` executable, or any other executable that is always expected to ask for AWS credentials, with a malicious wrapper that snarfs the credentials before passing them through to the original command. Creddy could defend against this to a certain extent by storing the hash of the executable, as discussed above.
|
||||
|
||||
### Pretending to be the user
|
||||
|
||||
Most desktop environments don't prevent applications from simulating user-input events such as mouse clicks and keypresses. An attacker could issue a credentials request, then immediately simulate whatever hotkey or mouse click Creddy normally interprets as "confirm this request". To mitigate this Creddy could implement a minimum time for which it _must_ be on screen before dismissal. The attacker could try to wait for the machine to be unattended before executing this attack, but this is chancy and could still result in detection. The request would still be logged in any case.
|
||||
|
||||
### Twiddling with Creddy's persistent state
|
||||
|
||||
The solutions to or mitigations for a lot of these attacks rely on Creddy being able to assume that its local database hasn't been tampered with. Unfortunately, given that our threat model is "other code running as the same user", this isn't a safe assumption.
|
16
index.html
16
index.html
@ -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
1727
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
7
src-tauri/.cargo/config.toml
Normal file
7
src-tauri/.cargo/config.toml
Normal 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
1
src-tauri/.env
Normal file
@ -0,0 +1 @@
|
||||
DATABASE_URL=sqlite://creddy.db?mode=rwc
|
1200
src-tauri/Cargo.lock
generated
1200
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -17,10 +17,21 @@ 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.0.5", features = ["api-all", "system-tray"] }
|
||||
sodiumoxide = "0.2.7"
|
||||
tokio = { version = ">=1.19", features = ["full"] }
|
||||
# futures = ">=0.3.21"
|
||||
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"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
@ -29,3 +40,6 @@ 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" ]
|
||||
|
||||
# [profile.dev.build-override]
|
||||
# opt-level = 3
|
||||
|
18
src-tauri/migrations/20221201002355_initial.sql
Normal file
18
src-tauri/migrations/20221201002355_initial.sql
Normal file
@ -0,0 +1,18 @@
|
||||
-- Add migration script here
|
||||
CREATE TABLE credentials (
|
||||
access_key_id TEXT NOT NULL,
|
||||
secret_key_enc BLOB NOT NULL,
|
||||
salt BLOB NOT NULL,
|
||||
nonce BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE config (
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE clients (
|
||||
name TEXT,
|
||||
path TEXT
|
||||
);
|
64
src-tauri/src/clientinfo.rs
Normal file
64
src-tauri/src/clientinfo.rs
Normal file
@ -0,0 +1,64 @@
|
||||
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
|
||||
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use crate::errors::*;
|
||||
use crate::get_state;
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||
pub struct Client {
|
||||
pub pid: u32,
|
||||
pub exe: String,
|
||||
}
|
||||
|
||||
|
||||
fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
|
||||
let sockets_iter = netstat2::iterate_sockets_info(
|
||||
AddressFamilyFlags::IPV4,
|
||||
ProtocolFlags::TCP
|
||||
)?;
|
||||
|
||||
get_state!(config as app_config);
|
||||
for item in sockets_iter {
|
||||
let sock_info = item?;
|
||||
let proto_info = match sock_info.protocol_socket_info {
|
||||
ProtocolSocketInfo::Tcp(tcp_info) => tcp_info,
|
||||
ProtocolSocketInfo::Udp(_) => {continue;}
|
||||
};
|
||||
|
||||
if proto_info.local_port == local_port
|
||||
&& proto_info.remote_port == app_config.listen_port
|
||||
&& proto_info.local_addr == app_config.listen_addr
|
||||
&& proto_info.remote_addr == app_config.listen_addr
|
||||
{
|
||||
return Ok(sock_info.associated_pids)
|
||||
}
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
|
||||
// Theoretically, on some systems, multiple processes can share a socket
|
||||
pub 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_u32(p);
|
||||
sys.refresh_process(pid);
|
||||
let proc = sys.process(pid)
|
||||
.ok_or(ClientInfoError::ProcessNotFound)?;
|
||||
|
||||
let client = Client {
|
||||
pid: p,
|
||||
exe: proc.exe().to_string_lossy().into_owned(),
|
||||
};
|
||||
clients.push(Some(client));
|
||||
}
|
||||
|
||||
if clients.is_empty() {
|
||||
clients.push(None);
|
||||
}
|
||||
|
||||
Ok(clients)
|
||||
}
|
122
src-tauri/src/config.rs
Normal file
122
src-tauri/src/config.rs
Normal file
@ -0,0 +1,122 @@
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use auto_launch::AutoLaunchBuilder;
|
||||
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(enable: 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()?;
|
||||
|
||||
if enable {
|
||||
auto.enable()?;
|
||||
}
|
||||
else {
|
||||
auto.disable()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
pub fn get_or_create_db_path() -> PathBuf {
|
||||
if cfg!(debug_assertions) {
|
||||
return PathBuf::from("./creddy.db");
|
||||
}
|
||||
|
||||
let mut parent = std::env::var("HOME")
|
||||
.map(|h| {
|
||||
let mut p = PathBuf::from(h);
|
||||
p.push(".config");
|
||||
p
|
||||
})
|
||||
.unwrap_or(PathBuf::from("."));
|
||||
|
||||
parent.push("creddy.db");
|
||||
parent
|
||||
}
|
||||
|
||||
|
||||
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) }
|
245
src-tauri/src/errors.rs
Normal file
245
src-tauri/src/errors.rs
Normal file
@ -0,0 +1,245 @@
|
||||
use std::error::Error;
|
||||
use std::convert::AsRef;
|
||||
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 serde::{Serialize, Serializer, ser::SerializeMap};
|
||||
|
||||
|
||||
// pub struct SerializeError<E> {
|
||||
// pub err: E,
|
||||
// }
|
||||
|
||||
// impl<E: std::error::Error> Serialize for SerializeError<E>
|
||||
// {
|
||||
// fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
// let mut map = serializer.serialize_map(None)?;
|
||||
// map.serialize_entry("msg", &format!("{}", self.err))?;
|
||||
// if let Some(src) = self.err.source() {
|
||||
// let ser_src = SerializeError { err: src };
|
||||
// map.serialize_entry("source", &ser_src)?;
|
||||
// }
|
||||
// map.end()
|
||||
// }
|
||||
// }
|
||||
|
||||
// impl<E: std::error::Error> From<E> for SerializeError<E> {
|
||||
// fn from(err: E) -> Self {
|
||||
// SerializeError { err }
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
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
|
||||
#[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 when attempting to tell a request handler whether to release or deny credentials
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum SendResponseError {
|
||||
#[error("The specified credentials request was not found")]
|
||||
NotFound, // no request with the given id
|
||||
#[error("The specified request was already closed by the client")]
|
||||
Abandoned, // request has already been closed by client
|
||||
}
|
||||
|
||||
|
||||
// errors encountered while handling an HTTP request
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum RequestError {
|
||||
#[error("Error writing to stream: {0}")]
|
||||
StreamIOError(#[from] std::io::Error),
|
||||
// #[error("Received invalid UTF-8 in request")]
|
||||
// InvalidUtf8,
|
||||
// MalformedHttpRequest,
|
||||
#[error("HTTP request too large")]
|
||||
RequestTooLarge,
|
||||
#[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")]
|
||||
NoCredentials, // SDK returned successfully but credentials are None
|
||||
#[error("Error response from AWS SDK: {0}")]
|
||||
SdkError(#[from] AwsSdkError<GetSessionTokenError>),
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum UnlockError {
|
||||
#[error("App is not locked")]
|
||||
NotLocked,
|
||||
#[error("No saved credentials were found")]
|
||||
NoCredentials,
|
||||
#[error("Invalid passphrase")]
|
||||
BadPassphrase,
|
||||
#[error("Data was found to be corrupt after decryption")]
|
||||
InvalidUtf8, // Somehow we got invalid utf-8 even though decryption succeeded
|
||||
#[error("Database error: {0}")]
|
||||
DbError(#[from] SqlxError),
|
||||
#[error("Failed to create AWS session: {0}")]
|
||||
GetSession(#[from] GetSessionError),
|
||||
}
|
||||
|
||||
|
||||
// Errors encountered while trying to figure out who's on the other end of a request
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum ClientInfoError {
|
||||
#[error("Found PID for client socket, but no corresponding process")]
|
||||
ProcessNotFound,
|
||||
#[error("Couldn't get client socket details: {0}")]
|
||||
NetstatError(#[from] netstat2::error::Error),
|
||||
}
|
||||
|
||||
|
||||
// =========================
|
||||
// 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!(SendResponseError);
|
||||
impl_serialize_basic!(GetCredentialsError);
|
||||
impl_serialize_basic!(ClientInfoError);
|
||||
|
||||
|
||||
impl Serialize for RequestError {
|
||||
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 {
|
||||
RequestError::NoCredentials(src) => map.serialize_entry("source", &src)?,
|
||||
RequestError::ClientInfo(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()
|
||||
}
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::convert::From;
|
||||
use std::str::Utf8Error;
|
||||
|
||||
// use tokio::sync::oneshot::error::RecvError;
|
||||
|
||||
|
||||
// Represents errors encountered while handling an HTTP request
|
||||
pub enum RequestError {
|
||||
StreamIOError(std::io::Error),
|
||||
InvalidUtf8,
|
||||
MalformedHttpRequest,
|
||||
RequestTooLarge,
|
||||
}
|
||||
|
||||
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<RecvError> for RequestError {
|
||||
// fn from (_e: RecvError) -> RequestError {
|
||||
// RequestError::
|
||||
// }
|
||||
// }
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
use std::io;
|
||||
use std::net::SocketAddrV4;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
mod errors;
|
||||
use errors::RequestError;
|
||||
|
||||
|
||||
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}");
|
||||
}
|
||||
});
|
||||
},
|
||||
Err(e) => {
|
||||
println!("Error accepting connection: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// it doesn't really return a String, we just need to placate the compiler
|
||||
async fn stall(stream: &mut TcpStream) -> Result<String, 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 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 creds = tokio::select!{
|
||||
r = stall(&mut stream) => r?, // this will never return Ok, just Err if it can't write to the stream
|
||||
c = get_creds(&app_handle) => c?,
|
||||
};
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
use tokio::io::{stdin, stdout, BufReader, AsyncBufReadExt};
|
||||
use crate::storage;
|
||||
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
async fn get_creds(app_handle: &AppHandle) -> io::Result<String> {
|
||||
app_handle.emit_all("credentials-request", ()).unwrap();
|
||||
|
||||
// let mut out = stdout();
|
||||
// out.write_all(b"Enter passphrase: ").await?;
|
||||
// out.flush().await?;
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
app_handle.once_global("passphrase-entered", |event| {
|
||||
match event.payload() {
|
||||
Some(p) => {tx.send(p.to_string());}
|
||||
None => {tx.send("".to_string());} // will fail decryption, we just need to unblock the outer function
|
||||
}
|
||||
});
|
||||
// Error is only returned if the rx is closed/dropped before receiving, which should never happen
|
||||
let passphrase = rx.await.unwrap();
|
||||
|
||||
// let mut passphrase = String::new();
|
||||
// let mut reader = BufReader::new(stdin());
|
||||
// reader.read_line(&mut passphrase).await?;
|
||||
|
||||
Ok(storage::load(&passphrase.trim()))
|
||||
}
|
77
src-tauri/src/ipc.rs
Normal file
77
src-tauri/src/ipc.rs
Normal file
@ -0,0 +1,77 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::errors::*;
|
||||
use crate::config::AppConfig;
|
||||
use crate::clientinfo::Client;
|
||||
use crate::state::{AppState, Session, Credentials};
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub id: u64,
|
||||
pub clients: Vec<Option<Client>>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RequestResponse {
|
||||
pub id: u64,
|
||||
pub approval: Approval,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum Approval {
|
||||
Approved,
|
||||
Denied,
|
||||
}
|
||||
|
||||
|
||||
#[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}"))
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Result<(), UnlockError> {
|
||||
app_state.decrypt(&passphrase).await
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_session_status(app_state: State<'_, AppState>) -> String {
|
||||
let session = app_state.session.read().unwrap();
|
||||
match *session {
|
||||
Session::Locked(_) => "locked".into(),
|
||||
Session::Unlocked(_) => "unlocked".into(),
|
||||
Session::Empty => "empty".into()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_credentials(
|
||||
credentials: Credentials,
|
||||
passphrase: String,
|
||||
app_state: State<'_, AppState>
|
||||
) -> Result<(), UnlockError> {
|
||||
app_state.save_creds(credentials, &passphrase).await
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_config(app_state: State<'_, AppState>) -> AppConfig {
|
||||
let config = app_state.config.read().unwrap();
|
||||
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 to database: {e}"))
|
||||
}
|
@ -3,28 +3,101 @@
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use std::str::FromStr;
|
||||
// use tokio::runtime::Runtime;
|
||||
use tauri::{AppHandle, Manager, async_runtime as rt};
|
||||
use once_cell::sync::OnceCell;
|
||||
|
||||
mod storage;
|
||||
mod http;
|
||||
mod config;
|
||||
mod errors;
|
||||
mod clientinfo;
|
||||
mod ipc;
|
||||
mod state;
|
||||
mod server;
|
||||
mod tray;
|
||||
|
||||
use crate::errors::*;
|
||||
use state::AppState;
|
||||
|
||||
|
||||
pub static APP: OnceCell<AppHandle> = OnceCell::new();
|
||||
|
||||
fn main() {
|
||||
let initial_state = match rt::block_on(AppState::load()) {
|
||||
Ok(state) => state,
|
||||
Err(e) => {eprintln!("{}", e); return;}
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(initial_state)
|
||||
.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| {
|
||||
let addr = std::net::SocketAddrV4::from_str("127.0.0.1:12345").unwrap();
|
||||
tauri::async_runtime::spawn(http::serve(addr, app.handle()));
|
||||
APP.set(app.handle()).unwrap();
|
||||
let state = app.state::<AppState>();
|
||||
let config = state.config.read().unwrap();
|
||||
config::set_auto_launch(config.start_on_login)?;
|
||||
|
||||
let addr = std::net::SocketAddrV4::new(config.listen_addr, config.listen_port);
|
||||
tauri::async_runtime::spawn(server::serve(addr, app.handle()));
|
||||
|
||||
if !config.start_minimized {
|
||||
app.get_window("main")
|
||||
.ok_or(RequestError::NoMainWindow)?
|
||||
.show()?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
// let addr = std::net::SocketAddrV4::from_str("127.0.0.1:12345").unwrap();
|
||||
// let rt = Runtime::new().unwrap();
|
||||
|
||||
// rt.block_on(http::serve(addr)).unwrap();
|
||||
|
||||
// let creds = std::fs::read_to_string("credentials.json").unwrap();
|
||||
// storage::save(&creds, "correct horse battery staple");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application")
|
||||
.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();
|
||||
}
|
||||
_ => ()
|
||||
}
|
||||
_ => ()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
macro_rules! get_state {
|
||||
($prop:ident as $name:ident) => {
|
||||
use tauri::Manager;
|
||||
let app = crate::APP.get().unwrap(); // as long as the app is running, this is fine
|
||||
let state = app.state::<crate::state::AppState>();
|
||||
let $name = state.$prop.read().unwrap(); // only panics if another thread has already panicked
|
||||
};
|
||||
(config.$prop:ident as $name:ident) => {
|
||||
use tauri::Manager;
|
||||
let app = crate::APP.get().unwrap();
|
||||
let state = app.state::<crate::state::AppState>();
|
||||
let config = state.config.read().unwrap();
|
||||
let $name = config.$prop;
|
||||
};
|
||||
|
||||
(mut $prop:ident as $name:ident) => {
|
||||
use tauri::Manager;
|
||||
let app = crate::APP.get().unwrap();
|
||||
let state = app.state::<crate::state::AppState>();
|
||||
let $name = state.$prop.write().unwrap();
|
||||
};
|
||||
(mut config.$prop:ident as $name:ident) => {
|
||||
use tauri::Manager;
|
||||
let app = crate::APP.get().unwrap();
|
||||
let state = app.state::<crate::state::AppState>();
|
||||
let config = state.config.write().unwrap();
|
||||
let $name = config.$prop;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub(crate) use get_state;
|
||||
|
176
src-tauri/src/server.rs
Normal file
176
src-tauri/src/server.rs
Normal file
@ -0,0 +1,176 @@
|
||||
use core::time::Duration;
|
||||
use std::io;
|
||||
use std::net::{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 crate::{clientinfo, clientinfo::Client};
|
||||
use crate::errors::*;
|
||||
use crate::ipc::{Request, Approval};
|
||||
use crate::state::AppState;
|
||||
|
||||
|
||||
struct Handler {
|
||||
request_id: u64,
|
||||
stream: TcpStream,
|
||||
receiver: Option<oneshot::Receiver<Approval>>,
|
||||
app: AppHandle,
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
async fn try_handle(&mut self) -> Result<(), RequestError> {
|
||||
let _ = self.recv_request().await?;
|
||||
let clients = self.get_clients()?;
|
||||
if self.includes_banned(&clients) {
|
||||
self.stream.write(b"HTTP/1.0 403 Access Denied\r\n\r\n").await?;
|
||||
return Ok(())
|
||||
}
|
||||
|
||||
let req = Request {id: self.request_id, clients};
|
||||
self.app.emit_all("credentials-request", &req)?;
|
||||
let starting_visibility = self.show_window()?;
|
||||
|
||||
match self.wait_for_response().await? {
|
||||
Approval::Approved => self.send_credentials().await?,
|
||||
Approval::Denied => {
|
||||
let state = self.app.state::<AppState>();
|
||||
for client in req.clients {
|
||||
state.add_ban(client, self.app.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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().unwrap();
|
||||
Duration::from_millis(config.rehide_ms)
|
||||
};
|
||||
sleep(delay).await;
|
||||
|
||||
if !starting_visibility && state.req_count() == 0 {
|
||||
let window = self.app.get_window("main").ok_or(RequestError::NoMainWindow)?;
|
||||
window.hide()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv_request(&mut self) -> Result<Vec<u8>, RequestError> {
|
||||
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(RequestError::RequestTooLarge);}
|
||||
}
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
println!("{}", std::str::from_utf8(&buf).unwrap());
|
||||
}
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn get_clients(&self) -> Result<Vec<Option<Client>>, RequestError> {
|
||||
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())?;
|
||||
Ok(clients)
|
||||
}
|
||||
|
||||
fn includes_banned(&self, clients: &Vec<Option<Client>>) -> bool {
|
||||
let state = self.app.state::<AppState>();
|
||||
clients.iter().any(|c| state.is_banned(c))
|
||||
}
|
||||
|
||||
fn show_window(&self) -> Result<bool, RequestError> {
|
||||
let window = self.app.get_window("main").ok_or(RequestError::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, RequestError> {
|
||||
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_credentials(&mut self) -> Result<(), RequestError> {
|
||||
let state = self.app.state::<AppState>();
|
||||
let creds = state.get_creds_serialized()?;
|
||||
|
||||
self.stream.write(b"\r\nContent-Length: ").await?;
|
||||
self.stream.write(creds.as_bytes().len().to_string().as_bytes()).await?;
|
||||
self.stream.write(b"\r\n\r\n").await?;
|
||||
self.stream.write(creds.as_bytes()).await?;
|
||||
self.stream.write(b"\r\n\r\n").await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn serve(addr: SocketAddrV4, app_handle: AppHandle) -> io::Result<()> {
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
println!("Listening on {addr}");
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _)) => {
|
||||
let handler = Handler::new(stream, app_handle.app_handle());
|
||||
tauri::async_runtime::spawn(handler.handle());
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Error accepting connection: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
299
src-tauri/src/state.rs
Normal file
299
src-tauri/src/state.rs
Normal file
@ -0,0 +1,299 @@
|
||||
use core::time::Duration;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
use tokio::sync::oneshot::Sender;
|
||||
use tokio::time::sleep;
|
||||
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions, sqlite::SqliteConnectOptions};
|
||||
use sodiumoxide::crypto::{
|
||||
pwhash,
|
||||
pwhash::Salt,
|
||||
secretbox,
|
||||
secretbox::{Nonce, Key}
|
||||
};
|
||||
use tauri::async_runtime as runtime;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::{config, config::AppConfig};
|
||||
use crate::ipc;
|
||||
use crate::clientinfo::Client;
|
||||
use crate::errors::*;
|
||||
|
||||
|
||||
#[derive(Debug, 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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LockedCredentials {
|
||||
access_key_id: String,
|
||||
secret_key_enc: Vec<u8>,
|
||||
salt: Salt,
|
||||
nonce: Nonce,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Session {
|
||||
Unlocked(Credentials),
|
||||
Locked(LockedCredentials),
|
||||
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>>>,
|
||||
pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn load() -> Result<Self, 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?;
|
||||
let creds = Self::load_creds(&pool).await?;
|
||||
let conf = AppConfig::load(&pool).await?;
|
||||
|
||||
let state = AppState {
|
||||
config: RwLock::new(conf),
|
||||
session: RwLock::new(creds),
|
||||
request_count: RwLock::new(0),
|
||||
open_requests: RwLock::new(HashMap::new()),
|
||||
bans: RwLock::new(HashSet::new()),
|
||||
pool,
|
||||
};
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
async fn load_creds(pool: &SqlitePool) -> Result<Session, 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_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<(), UnlockError> {
|
||||
let (key_id, secret_key) = match creds {
|
||||
Credentials::LongLived {access_key_id, secret_access_key} => {
|
||||
(access_key_id, secret_access_key)
|
||||
},
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// do this first so that if it fails we don't save bad credentials
|
||||
self.new_session(&key_id, &secret_key).await?;
|
||||
|
||||
let salt = pwhash::gen_salt();
|
||||
let mut key_buf = [0; secretbox::KEYBYTES];
|
||||
pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), &salt).unwrap();
|
||||
let key = Key(key_buf);
|
||||
// not sure we need both salt AND nonce given that we generate a
|
||||
// fresh salt every time we encrypt, but better safe than sorry
|
||||
let nonce = secretbox::gen_nonce();
|
||||
let secret_key_enc = secretbox::seal(secret_key.as_bytes(), &nonce, &key);
|
||||
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO credentials (access_key_id, secret_key_enc, salt, nonce, created_at)
|
||||
VALUES (?, ?, ?, ?, strftime('%s'))"
|
||||
)
|
||||
.bind(&key_id)
|
||||
.bind(&secret_key_enc)
|
||||
.bind(&salt.0[0..])
|
||||
.bind(&nonce.0[0..])
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
|
||||
new_config.save(&self.pool).await?;
|
||||
|
||||
let mut live_config = self.config.write().unwrap();
|
||||
if new_config.start_on_login != live_config.start_on_login {
|
||||
config::set_auto_launch(new_config.start_on_login)?;
|
||||
}
|
||||
*live_config = new_config;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
|
||||
let count = {
|
||||
let mut c = self.request_count.write().unwrap();
|
||||
*c += 1;
|
||||
c
|
||||
};
|
||||
|
||||
let mut open_requests = self.open_requests.write().unwrap();
|
||||
open_requests.insert(*count, chan); // `count` is the request id
|
||||
*count
|
||||
}
|
||||
|
||||
pub fn unregister_request(&self, id: u64) {
|
||||
let mut open_requests = self.open_requests.write().unwrap();
|
||||
open_requests.remove(&id);
|
||||
}
|
||||
|
||||
pub fn req_count(&self) -> usize {
|
||||
let open_requests = self.open_requests.read().unwrap();
|
||||
open_requests.len()
|
||||
}
|
||||
|
||||
pub fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
|
||||
let mut open_requests = self.open_requests.write().unwrap();
|
||||
let chan = open_requests
|
||||
.remove(&response.id)
|
||||
.ok_or(SendResponseError::NotFound)
|
||||
?;
|
||||
|
||||
chan.send(response.approval)
|
||||
.map_err(|_e| SendResponseError::Abandoned)
|
||||
}
|
||||
|
||||
pub fn add_ban(&self, client: Option<Client>, app: tauri::AppHandle) {
|
||||
let mut bans = self.bans.write().unwrap();
|
||||
bans.insert(client.clone());
|
||||
|
||||
runtime::spawn(async move {
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
let state = app.state::<AppState>();
|
||||
let mut bans = state.bans.write().unwrap();
|
||||
bans.remove(&client);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn is_banned(&self, client: &Option<Client>) -> bool {
|
||||
self.bans.read().unwrap().contains(&client)
|
||||
}
|
||||
|
||||
pub async fn decrypt(&self, passphrase: &str) -> Result<(), UnlockError> {
|
||||
let (key_id, secret) = {
|
||||
// do this all in a block so that we aren't holding a lock across an await
|
||||
let session = self.session.read().unwrap();
|
||||
let locked = match *session {
|
||||
Session::Empty => {return Err(UnlockError::NoCredentials);},
|
||||
Session::Unlocked(_) => {return Err(UnlockError::NotLocked);},
|
||||
Session::Locked(ref c) => c,
|
||||
};
|
||||
|
||||
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)?;
|
||||
(locked.access_key_id.clone(), secret_str)
|
||||
};
|
||||
|
||||
self.new_session(&key_id, &secret).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_creds_serialized(&self) -> Result<String, GetCredentialsError> {
|
||||
let session = self.session.read().unwrap();
|
||||
match *session {
|
||||
Session::Unlocked(ref creds) => Ok(serde_json::to_string(creds).unwrap()),
|
||||
Session::Locked(_) => Err(GetCredentialsError::Locked),
|
||||
Session::Empty => Err(GetCredentialsError::Empty),
|
||||
}
|
||||
}
|
||||
|
||||
async fn new_session(&self, key_id: &str, secret_key: &str) -> Result<(), GetSessionError> {
|
||||
let creds = aws_sdk_sts::Credentials::new(
|
||||
key_id,
|
||||
secret_key,
|
||||
None, // token
|
||||
None, // expiration
|
||||
"creddy", // "provider name" apparently
|
||||
);
|
||||
let config = aws_config::from_env()
|
||||
.credentials_provider(creds)
|
||||
.load()
|
||||
.await;
|
||||
|
||||
let client = aws_sdk_sts::Client::new(&config);
|
||||
let resp = client.get_session_token()
|
||||
.duration_seconds(43_200)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let aws_session = resp.credentials().ok_or(GetSessionError::NoCredentials)?;
|
||||
|
||||
let access_key_id = aws_session.access_key_id()
|
||||
.ok_or(GetSessionError::NoCredentials)?
|
||||
.to_string();
|
||||
let secret_access_key = aws_session.secret_access_key()
|
||||
.ok_or(GetSessionError::NoCredentials)?
|
||||
.to_string();
|
||||
let token = aws_session.session_token()
|
||||
.ok_or(GetSessionError::NoCredentials)?
|
||||
.to_string();
|
||||
let expiration = aws_session.expiration()
|
||||
.ok_or(GetSessionError::NoCredentials)?
|
||||
.fmt(aws_smithy_types::date_time::Format::DateTime)
|
||||
.unwrap(); // only fails if the d/t is out of range, which it can't be for this format
|
||||
|
||||
let mut app_session = self.session.write().unwrap();
|
||||
let session_creds = Credentials::ShortLived {
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
token,
|
||||
expiration,
|
||||
};
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
println!("Got new session:\n{}", serde_json::to_string(&session_creds).unwrap());
|
||||
}
|
||||
|
||||
*app_session = Session::Unlocked(session_creds);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
use sodiumoxide::crypto::{pwhash, secretbox};
|
||||
|
||||
|
||||
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")
|
||||
}
|
36
src-tauri/src/tray.rs
Normal file
36
src-tauri/src/tray.rs
Normal 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());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
@ -29,7 +29,7 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "com.tauri.dev",
|
||||
"identifier": "creddy",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
@ -58,9 +58,15 @@
|
||||
"fullscreen": false,
|
||||
"height": 600,
|
||||
"resizable": true,
|
||||
"label": "main",
|
||||
"title": "Creddy",
|
||||
"width": 800
|
||||
"width": 800,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"systemTray": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"iconAsTemplate": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -1,13 +1,20 @@
|
||||
<script>
|
||||
import { emit, listen } from '@tauri-apps/api/event';
|
||||
import Home from './views/Home.svelte';
|
||||
import Approve from './views/Approve.svelte';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
let activeComponent = Home;
|
||||
import { appState } from './lib/state.js';
|
||||
import { views, currentView, navigate } from './lib/routing.js';
|
||||
|
||||
listen('credentials-request', (event) => {
|
||||
activeComponent = Approve;
|
||||
})
|
||||
|
||||
$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);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:component this={activeComponent} />
|
||||
|
||||
<svelte:component this="{$currentView}" />
|
||||
|
8
src/lib/errors.js
Normal file
8
src/lib/errors.js
Normal file
@ -0,0 +1,8 @@
|
||||
export function getRootCause(error) {
|
||||
if (error.source) {
|
||||
return getRootCause(error.source);
|
||||
}
|
||||
else {
|
||||
return error;
|
||||
}
|
||||
}
|
@ -11,8 +11,8 @@ export default function() {
|
||||
|
||||
put(item) {
|
||||
this.items.push(item);
|
||||
if (this.resolvers.length > 0) {
|
||||
let resolver = this.resolvers.shift();
|
||||
if (resolver) {
|
||||
resolver();
|
||||
}
|
||||
},
|
||||
|
10
src/lib/routing.js
Normal file
10
src/lib/routing.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
|
||||
|
||||
export let views = writable();
|
||||
export let currentView = writable();
|
||||
|
||||
export function navigate(viewName) {
|
||||
let v = get(views)[`./views/${viewName}.svelte`].default;
|
||||
currentView.set(v)
|
||||
}
|
9
src/lib/state.js
Normal file
9
src/lib/state.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
import queue from './queue.js';
|
||||
|
||||
export let appState = writable({
|
||||
currentRequest: null,
|
||||
pendingRequests: queue(),
|
||||
credentialStatus: 'locked',
|
||||
});
|
9
src/ui/Button.svelte
Normal file
9
src/ui/Button.svelte
Normal 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
67
src/ui/ErrorAlert.svelte
Normal 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
11
src/ui/Icon.svelte
Normal 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
43
src/ui/Link.svelte
Normal 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;
|
||||
|
||||
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.code === hotkey) {
|
||||
click();
|
||||
}
|
||||
else if (hotkey === 'Enter' && event.code === 'NumpadEnter') {
|
||||
click();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<svelte:window on:keydown={handleHotkey} />
|
||||
|
||||
<a href="#" on:click="{click}">
|
||||
<slot></slot>
|
||||
</a>
|
23
src/ui/Nav.svelte
Normal file
23
src/ui/Nav.svelte
Normal file
@ -0,0 +1,23 @@
|
||||
<script>
|
||||
import Link from './Link.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
</script>
|
||||
|
||||
|
||||
<nav class="fixed top-0 grid grid-cols-2 w-full 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>
|
||||
|
||||
<div class="justify-self-end">
|
||||
<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>
|
8
src/ui/icons/check-circle.svelte
Normal file
8
src/ui/icons/check-circle.svelte
Normal 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>
|
9
src/ui/icons/cog-8-tooth.svelte
Normal file
9
src/ui/icons/cog-8-tooth.svelte
Normal 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
8
src/ui/icons/home.svelte
Normal 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>
|
8
src/ui/icons/x-circle.svelte
Normal file
8
src/ui/icons/x-circle.svelte
Normal 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>
|
62
src/ui/settings/NumericSetting.svelte
Normal file
62
src/ui/settings/NumericSetting.svelte
Normal file
@ -0,0 +1,62 @@
|
||||
<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;
|
||||
|
||||
let error = null;
|
||||
let localValue = value.toString();
|
||||
const dispatch = createEventDispatcher();
|
||||
function validate(event) {
|
||||
localValue = localValue.replace(/[^-0-9.]/g, '');
|
||||
// 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 (localValue.match(/^$|^-$|^\.$/) !== null) {
|
||||
error = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let num = parseFloat(localValue);
|
||||
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="{validate}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="description" slot="description"></slot>
|
||||
</Setting>
|
19
src/ui/settings/Setting.svelte
Normal file
19
src/ui/settings/Setting.svelte
Normal 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}
|
22
src/ui/settings/ToggleSetting.svelte
Normal file
22
src/ui/settings/ToggleSetting.svelte
Normal file
@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import Setting from './Setting.svelte';
|
||||
|
||||
export let title;
|
||||
export let value;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
|
||||
<Setting {title}>
|
||||
<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
3
src/ui/settings/index.js
Normal 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';
|
@ -1,19 +1,59 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import { appState } from '../lib/state.js';
|
||||
import Link from '../ui/Link.svelte';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
|
||||
|
||||
async function approve() {
|
||||
let status = await invoke('get_session_status');
|
||||
if (status === 'unlocked') {
|
||||
navigate('ShowApproved');
|
||||
}
|
||||
else if (status === 'locked') {
|
||||
navigate('Unlock');
|
||||
}
|
||||
else {
|
||||
navigate('EnterCredentials');
|
||||
}
|
||||
}
|
||||
|
||||
var appName = null;
|
||||
if ($appState.currentRequest.clients.length === 1) {
|
||||
let path = $appState.currentRequest.clients[0].exe;
|
||||
let m = path.match(/\/([^/]+?$)|\\([^\\]+?$)/);
|
||||
appName = m[1] || m[2];
|
||||
}
|
||||
</script>
|
||||
|
||||
<h2 class="text-3xl text-gray-200">An application would like to access your AWS credentials.</h2>
|
||||
|
||||
<button on:click={() => dispatch('response', 'approved')}>
|
||||
<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>
|
||||
<div class="flex flex-col space-y-4 p-4 m-auto max-w-max h-screen justify-center">
|
||||
<!-- <div class="p-4 rounded-box border-2 border-neutral-content"> -->
|
||||
<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>
|
||||
{#each $appState.currentRequest.clients as client}
|
||||
<p>Path: {client ? client.exe : 'Unknown'}</p>
|
||||
<p>PID: {client ? client.pid : 'Unknown'}</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button on:click={() => dispatch('response', 'denied')}>
|
||||
<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>
|
||||
<div class="grid grid-cols-2">
|
||||
<Link target="ShowDenied" 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>
|
72
src/views/EnterCredentials.svelte
Normal file
72
src/views/EnterCredentials.svelte
Normal file
@ -0,0 +1,72 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import { getRootCause } from '../lib/errors.js';
|
||||
|
||||
import { appState } from '../lib/state.js';
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import Link from '../ui/Link.svelte';
|
||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||
|
||||
|
||||
let errorMsg = null;
|
||||
let alert;
|
||||
let AccessKeyId, SecretAccessKey, passphrase, confirmPassphrase
|
||||
|
||||
function confirm() {
|
||||
if (passphrase !== confirmPassphrase) {
|
||||
errorMsg = 'Passphrases do not match.'
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (passphrase !== confirmPassphrase) {
|
||||
alert.shake();
|
||||
return;
|
||||
}
|
||||
|
||||
let credentials = {AccessKeyId, SecretAccessKey};
|
||||
try {
|
||||
await invoke('save_credentials', {credentials, passphrase});
|
||||
if ($appState.currentRequest) {
|
||||
navigate('ShowApproved');
|
||||
}
|
||||
else {
|
||||
navigate('Home');
|
||||
}
|
||||
}
|
||||
catch (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();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
{#if errorMsg}
|
||||
<ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert>
|
||||
{/if}
|
||||
|
||||
<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} />
|
||||
|
||||
<input type="submit" class="btn btn-primary" />
|
||||
<Link target="Home" hotkey="Escape">
|
||||
<button class="btn btn-sm btn-outline w-full">Cancel</button>
|
||||
</Link>
|
||||
</form>
|
@ -1 +1,42 @@
|
||||
<h1 class="text-4xl text-gray-300">Creddy</h1>
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
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';
|
||||
|
||||
|
||||
onMount(async () => {
|
||||
// will block until a request comes in
|
||||
let req = await $appState.pendingRequests.get();
|
||||
$appState.currentRequest = req;
|
||||
navigate('Approve');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<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'}
|
||||
<img src="/static/padlock-closed.svg" alt="A locked padlock" class="w-32" />
|
||||
<h2 class="text-2xl font-bold">Creddy is locked</h2>
|
||||
<Link target="Unlock">
|
||||
<button class="btn btn-primary">Unlock</button>
|
||||
</Link>
|
||||
|
||||
{:else if status === 'unlocked'}
|
||||
<img src="/static/padlock-open.svg" alt="An unlocked padlock" class="w-24" />
|
||||
<h2 class="text-2xl font-bold">Waiting for requests</h2>
|
||||
|
||||
{:else if status === 'empty'}
|
||||
<Link target="EnterCredentials">
|
||||
<button class="btn btn-primary">Enter Credentials</button>
|
||||
</Link>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
59
src/views/Settings.svelte
Normal file
59
src/views/Settings.svelte
Normal file
@ -0,0 +1,59 @@
|
||||
<script>
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
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 from '../ui/settings/Setting.svelte';
|
||||
import { Setting, ToggleSetting, NumericSetting } from '../ui/settings';
|
||||
|
||||
async function save() {
|
||||
await invoke('save_config', {config: $appState.config});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<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=1 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}
|
78
src/views/ShowApproved.svelte
Normal file
78
src/views/ShowApproved.svelte
Normal file
@ -0,0 +1,78 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { draw, fade } from 'svelte/transition';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
import { appState } from '../lib/state.js';
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import Link from '../ui/Link.svelte';
|
||||
|
||||
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;
|
||||
|
||||
async function respond() {
|
||||
let response = {
|
||||
id: $appState.currentRequest.id,
|
||||
approval: 'Approved',
|
||||
};
|
||||
|
||||
try {
|
||||
await invoke('respond', {response});
|
||||
success = true;
|
||||
$appState.currentRequest = null;
|
||||
|
||||
window.setTimeout(
|
||||
() => navigate('Home'),
|
||||
// Extra 50ms so the window can finish disappearing before the screen changes
|
||||
Math.min(5000, $appState.config.rehide_ms + 50),
|
||||
);
|
||||
}
|
||||
catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
}
|
||||
onMount(respond);
|
||||
</script>
|
||||
|
||||
|
||||
{#if error}
|
||||
<div class="flex flex-col h-screen items-center justify-center m-auto max-w-lg">
|
||||
<ErrorAlert>
|
||||
{error}
|
||||
<svelte:fragment slot="buttons">
|
||||
<Link target="Home">
|
||||
<button class="btn btn-sm bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content">
|
||||
Ok
|
||||
</button>
|
||||
</Link>
|
||||
</svelte:fragment>
|
||||
</ErrorAlert>
|
||||
</div>
|
||||
{:else if success}
|
||||
<div class="flex flex-col h-screen items-center justify-center max-w-max m-auto">
|
||||
<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>
|
||||
|
||||
|
||||
<div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold">Approved!</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
|
||||
<!--
|
||||
{#if error}
|
||||
<div class="text-red-400">{error}</div>
|
||||
{:else}
|
||||
<h1 class="text-4xl text-gray-300">Approved!</h1>
|
||||
{/if}
|
||||
-->
|
56
src/views/ShowDenied.svelte
Normal file
56
src/views/ShowDenied.svelte
Normal file
@ -0,0 +1,56 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { draw, fade } from 'svelte/transition';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
import { appState } from '../lib/state.js';
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import Link from '../ui/Link.svelte';
|
||||
|
||||
let error = null;
|
||||
|
||||
async function respond() {
|
||||
let response = {
|
||||
id: $appState.currentRequest.id,
|
||||
approval: 'Denied',
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('respond', {response});
|
||||
$appState.currentRequest = null;
|
||||
window.setTimeout(() => navigate('Home'), 1000);
|
||||
}
|
||||
catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
onMount(respond);
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="flex flex-col h-screen items-center justify-center m-auto max-w-lg">
|
||||
<ErrorAlert>
|
||||
{error}
|
||||
<svelte:fragment slot="buttons">
|
||||
<Link target="Home">
|
||||
<button class="btn btn-sm bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content" on:click="{() => navigate('Home')}">
|
||||
Ok
|
||||
</button>
|
||||
</Link>
|
||||
</svelte:fragment>
|
||||
</ErrorAlert>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center h-screen max-w-max m-auto">
|
||||
<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>
|
||||
|
||||
<div in:fade="{{delay: 200, duration: 300}}" class="text-2xl font-bold">Denied!</div>
|
||||
</div>
|
||||
{/if}
|
56
src/views/Unlock.svelte
Normal file
56
src/views/Unlock.svelte
Normal file
@ -0,0 +1,56 @@
|
||||
<script>
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
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';
|
||||
|
||||
|
||||
let errorMsg = null;
|
||||
let alert;
|
||||
let passphrase = '';
|
||||
async function unlock() {
|
||||
try {
|
||||
let r = await invoke('unlock', {passphrase});
|
||||
$appState.credentialStatus = 'unlocked';
|
||||
if ($appState.currentRequest) {
|
||||
navigate('ShowApproved');
|
||||
}
|
||||
else {
|
||||
navigate('Home');
|
||||
}
|
||||
}
|
||||
catch (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();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<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}
|
||||
|
||||
<input autofocus name="password" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" class="input input-bordered" />
|
||||
|
||||
<input type="submit" class="btn btn-primary" />
|
||||
<Link target="Home" hotkey="Escape">
|
||||
<button class="btn btn-outline btn-sm w-full">Cancel</button>
|
||||
</Link>
|
||||
</form>
|
430
static/padlock-closed.svg
Normal file
430
static/padlock-closed.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 34 KiB |
467
static/padlock-open.svg
Normal file
467
static/padlock-open.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 46 KiB |
@ -7,5 +7,7 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [
|
||||
require('daisyui'),
|
||||
],
|
||||
}
|
||||
|
Reference in New Issue
Block a user