94 Commits

Author SHA1 Message Date
3d093a3a45 show version in cli 2023-09-14 15:22:38 -07:00
992d2a4d06 show non-fatal setup errors on home screen instead of in popup 2023-09-14 15:13:19 -07:00
12f0f187a6 update cargo and npm 2023-09-14 12:49:45 -07:00
997e8b419f handle setup errors more gracefully 2023-09-13 11:06:40 -07:00
1d9132de3b make hotkey configuration more timing tolerant 2023-09-12 21:46:25 -07:00
e1c2618dc8 bump version 2023-09-12 15:31:03 -07:00
a7df7adc8e Ignore keyup events for modifier keys 2023-09-12 15:27:15 -07:00
03d164c9d3 Inherit rehide flag from existing request if present 2023-09-12 14:10:57 -07:00
f522674a1c don't remove request from state until after re-hiding window 2023-09-12 11:47:33 -07:00
51fcccafa2 fix os type calculation and bump version 2023-09-11 16:18:05 -07:00
e3913ab4c9 add todo list 2023-09-11 16:11:06 -07:00
c16f21bba3 Merge branch 'terminal' 2023-09-11 16:10:58 -07:00
61d9acc7c6 request unlock/credentials when terminal is launched from locked/empty state 2023-09-11 16:00:58 -07:00
8d7b01629d make keybinds configurable 2023-09-10 14:04:09 -07:00
5685948608 add hotkeys to show window and launch terminal 2023-09-09 07:29:57 -07:00
c98a065587 make terminal emulator configurable 2023-09-09 06:30:19 -07:00
e46c3d2b4d tweak home screen 2023-09-05 06:12:26 -07:00
fa228acc3a use svg animation for spinner 2023-08-06 21:25:24 -07:00
e7e0f9d33e very basic launch button 2023-08-03 22:08:24 -07:00
a51b20add7 combine ExecError with LaunchError and use Session::try_get() instead of matching 2023-08-03 21:57:55 -07:00
890f715388 usable backend for terminal launch 2023-08-03 16:35:15 -07:00
89bc74e644 start working on terminal launcher 2023-08-02 19:57:37 -07:00
60c24e3ee4 don't autohide on first launch 2023-07-11 16:13:20 -07:00
486001b584 improve display of GetSessionError 2023-07-11 14:34:54 -07:00
52c949e396 v0.2.3 2023-07-11 10:35:56 -07:00
d7c5c2f37b update dependencies 2023-07-11 09:52:13 -07:00
ae5b8f31db remove spinner when unlock fails 2023-07-11 09:50:35 -07:00
c260e37e78 cryptography notes 2023-05-19 10:04:48 -07:00
7501253970 add separate binary for Windows CLI 2023-05-15 13:09:26 -07:00
5b9c711008 fix subprocess exec for unix 2023-05-09 09:47:11 -07:00
ddd1005067 switch crypto implementation and add spinner 2023-05-08 22:14:35 -07:00
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
10fd1d6028 more work on establishing credentials 2022-12-14 14:52:16 -08:00
67705aa2d1 add credentials entry view 2022-12-13 21:50:34 -08:00
9055fa41aa switch to invokes instead of emits 2022-12-13 16:50:44 -08:00
48269855e5 start listing some specific threats 2022-12-13 10:58:52 -08:00
1e4e1c9a5f encrypt/decrypt and db interaction 2022-12-03 21:47:09 -08:00
196510e9a2 connect to db on startup 2022-12-02 22:59:13 -08:00
e423df8e51 add to migration 2022-11-30 21:45:43 -08:00
2cfde4d841 first migration 2022-11-30 16:27:59 -08:00
7d462645b4 update readme because why not 2022-11-30 16:04:14 -08:00
8c271281f7 add security document 2022-11-30 15:29:41 -08:00
234d9e0471 fix some things 2022-11-29 16:13:09 -08:00
397928b8f1 reorganize backend 2022-11-28 16:16:33 -08:00
c19b573b26 all is change; in change is all again made new 2022-11-27 22:03:15 -08:00
cee43342b9 event-based routing? 2022-11-23 17:11:44 -08:00
65 changed files with 7556 additions and 1979 deletions

4
.gitignore vendored
View File

@ -1,8 +1,8 @@
dist
**/node_modules
src-tauri/target/
**/creddy.db
# just in case
credentials*
!credentials.rs

View File

@ -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.

9
doc/cryptography.md Normal file
View File

@ -0,0 +1,9 @@
My original plan was to use [libsodium](https://doc.libsodium.org/) to handle encryption. However, the Rust bindings for libsodium are no longer actively maintained, which left me uncomfortable with using it. Instead, I switched to the [RustCrypto](https://github.com/RustCrypto) implementations of the same (or nearly the same) cryptographic primitives provided by libsodium.
Creddy makes use of two cryptographic primitives: A key-derivation function, which is currently `argon2id`, and a symmetric encryption algorithm, currently `XChaCha20Poly1305`.
* I chose `argon2id` because it's what libsodium uses, and because its difficulty parameters admit of very granular tuning.
* I chose `XChaCha20Poly1305` because it's _almost_ what libsodium uses - libsodium uses `XSalsa20Poly1305`, and it's my undersatnding that `XChaCha20Poly1305` is an evolution of the former. In both cases I use the eXtended variants, which make use of longer (24-byte) nonces than the non-X variants. This appealed to me because I wanted to be able to randomly generate a nonce every time I needed one, and I have seen [recommendations](https://latacora.micro.blog/2018/04/03/cryptographic-right-answers.html) that the 12-byte nonces used by the non-X variants are _juuust_ a touch small for that to be truly worry-free. The RustCrypto implementation of `XChaCha20Poly1305` has also been subject to a security audit, which is nice.
I tuned the `argon2id` parameters so that key-derivation would take ~800ms on my Ryzen 1600X. This is probably overkill, but I don't intend for key-derivation to be a frequent occurrence - no more than once a day, under normal circumstances. Taking in the neighborhood of 1 second seemed about the longest I could reasonably go.
**DISCLAIMER**: I am not a professional cryptographer, merely an interested amateur. While I've tried to be as careful as possible with selecting and making use of the cryptographic building blocks I've chosen here, there is always the possibility that I've screwed something up. If anyone would like to sponsor an _actual_ security review of Creddy by people who _actually_ know what they're doing instead of just what they've read on the internet, please let me know.

48
doc/security.md Normal file
View 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.

18
doc/todo.md Normal file
View File

@ -0,0 +1,18 @@
## Definitely
* Switch to "process" provider for AWS credentials (much less hacky)
* Session timeout (plain duration, or activity-based?)
* ~Fix rehide behavior when new request comes in while old one is still being resolved~
* Additional hotkey configuration (approve/deny at the very least)
* Logging
* Icon
* Auto-updates
* SSH key handling
## Maybe
* Flatten error type hierarchy
* Rehide after terminal launch from locked
* Generalize Request across both credentials and terminal launch?
* Make hotkey configuration a little more tolerant of slight mistiming
* Distinguish between request that was denied and request that was canceled (e.g. due to error)

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>

1947
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.3.3",
"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

3745
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,22 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
name = "creddy"
version = "0.3.3"
description = "A friendly AWS credentials manager"
authors = ["Joseph Montanaro"]
license = ""
repository = ""
default-run = "app"
default-run = "creddy"
edition = "2021"
rust-version = "1.57"
[[bin]]
name = "creddy_cli"
path = "src/bin/creddy_cli.rs"
[[bin]]
name = "creddy"
path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
@ -17,15 +25,36 @@ 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", "dialog-open", "global-shortcut", "os-all", "system-tray"] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
sodiumoxide = "0.2.7"
tokio = { version = ">=1.19", features = ["full"] }
# 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"
dirs = "5.0"
clap = { version = "3.2.23", features = ["derive"] }
is-terminal = "0.4.7"
argon2 = { version = "0.5.0", features = ["std"] }
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
which = "4.4.0"
[features]
# 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

22
src-tauri/conf/cli.wxs Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Fragment>
<DirectoryRef Id="INSTALLDIR">
<!-- Create a subdirectory for the console binary so that we can add it to PATH -->
<Directory Id="BinDir" Name="bin">
<Component Id="CliBinary" Guid="b6358c8e-504f-41fd-b14b-38af821dcd04">
<!-- Same name as the main executable, so that it can be invoked as just "creddy" -->
<File Id="Bin_Cli" Source="..\..\creddy_cli.exe" Name="creddy.exe" KeyPath="yes"/>
</Component>
</Directory>
</DirectoryRef>
<DirectoryRef Id="TARGETDIR">
<Component Id="AddToPath" Guid="b5fdaf7e-94f2-4aad-9144-aa3a8edfa675">
<Environment Id="CreddyInstallDir" Action="set" Name="PATH" Part="last" Permanent="no" Value="[BinDir]" />
</Component>
</DirectoryRef>
</Fragment>
</Wix>

View File

@ -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
);

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

@ -0,0 +1,116 @@
use std::error::Error;
use once_cell::sync::OnceCell;
use sqlx::{
SqlitePool,
sqlite::SqlitePoolOptions,
sqlite::SqliteConnectOptions,
};
use tauri::{
App,
AppHandle,
Manager,
async_runtime as rt,
};
use crate::{
config::{self, AppConfig},
credentials::Session,
ipc,
server::Server,
errors::*,
state::AppState,
tray,
};
pub static APP: OnceCell<AppHandle> = OnceCell::new();
pub fn run() -> tauri::Result<()> {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
app.get_window("main")
.map(|w| w.show().error_popup("Failed to show main window"));
}))
.system_tray(tray::create())
.on_system_tray_event(tray::handle_event)
.invoke_handler(tauri::generate_handler![
ipc::unlock,
ipc::respond,
ipc::get_session_status,
ipc::save_credentials,
ipc::get_config,
ipc::save_config,
ipc::launch_terminal,
ipc::get_setup_errors,
])
.setup(|app| rt::block_on(setup(app)))
.build(tauri::generate_context!())?
.run(|app, run_event| match run_event {
tauri::RunEvent::WindowEvent { label, event, .. } => match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
let _ = app.get_window(&label).map(|w| w.hide());
api.prevent_close();
}
_ => ()
}
_ => ()
});
Ok(())
}
pub async fn connect_db() -> Result<SqlitePool, SetupError> {
let conn_opts = SqliteConnectOptions::new()
.filename(config::get_or_create_db_path()?)
.create_if_missing(true);
let pool_opts = SqlitePoolOptions::new();
let pool: SqlitePool = pool_opts.connect_with(conn_opts).await?;
sqlx::migrate!().run(&pool).await?;
Ok(pool)
}
async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
APP.set(app.handle()).unwrap();
// get_or_create_db_path doesn't create the actual db file, just the directory
let is_first_launch = !config::get_or_create_db_path()?.exists();
let pool = connect_db().await?;
let mut setup_errors: Vec<String> = vec![];
let conf = match AppConfig::load(&pool).await {
Ok(c) => c,
Err(SetupError::ConfigParseError(_)) => {
setup_errors.push(
"Could not load configuration from database. Reverting to defaults.".into()
);
AppConfig::default()
},
err => err?,
};
let session = Session::load(&pool).await?;
let srv = Server::new(conf.listen_addr, conf.listen_port, app.handle()).await?;
config::set_auto_launch(conf.start_on_login)?;
if let Err(_e) = config::set_auto_launch(conf.start_on_login) {
setup_errors.push("Error: Failed to manage autolaunch.".into());
}
if let Err(e) = config::register_hotkeys(&conf.hotkeys) {
setup_errors.push(format!("{e}"));
}
// if session is empty, this is probably the first launch, so don't autohide
if !conf.start_minimized || is_first_launch {
app.get_window("main")
.ok_or(HandlerError::NoMainWindow)?
.show()?;
}
let state = AppState::new(conf, session, srv, pool, setup_errors);
app.manage(state);
Ok(())
}

View File

@ -0,0 +1,45 @@
// Windows isn't really amenable to having a single executable work as both a CLI and GUI app,
// so we just have a second binary for CLI usage
use creddy::{
cli,
errors::CliError,
};
use std::{
env,
process::{self, Command},
};
fn main() {
let args = cli::parser().get_matches();
if let Some(true) = args.get_one::<bool>("help") {
cli::parser().print_help().unwrap(); // if we can't print help we can't print an error
process::exit(0);
}
let res = match args.subcommand() {
None | Some(("run", _)) => launch_gui(),
Some(("show", m)) => cli::show(m),
Some(("exec", m)) => cli::exec(m),
_ => unreachable!(),
};
if let Err(e) = res {
eprintln!("Error: {e}");
}
}
fn launch_gui() -> Result<(), CliError> {
let mut path = env::current_exe()?;
path.pop(); // bin dir
// binaries are colocated in dev, but not in production
#[cfg(not(debug_assertions))]
path.pop(); // install dir
path.push("creddy.exe"); // exe in main install dir (aka gui exe)
Command::new(path).spawn()?;
Ok(())
}

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

@ -0,0 +1,161 @@
use std::ffi::OsString;
use std::process::Command as ChildCommand;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use clap::{
Command,
Arg,
ArgMatches,
ArgAction
};
use tokio::{
net::TcpStream,
io::{AsyncReadExt, AsyncWriteExt},
};
use crate::app;
use crate::config::AppConfig;
use crate::credentials::{BaseCredentials, SessionCredentials};
use crate::errors::*;
pub fn parser() -> Command<'static> {
Command::new("creddy")
.version(env!("CARGO_PKG_VERSION"))
.about("A friendly AWS credentials manager")
.subcommand(
Command::new("run")
.about("Launch Creddy")
)
.subcommand(
Command::new("show")
.about("Fetch and display AWS credentials")
.arg(
Arg::new("base")
.short('b')
.long("base")
.action(ArgAction::SetTrue)
.help("Use base credentials instead of session credentials")
)
)
.subcommand(
Command::new("exec")
.about("Inject AWS credentials into the environment of another command")
.trailing_var_arg(true)
.arg(
Arg::new("base")
.short('b')
.long("base")
.action(ArgAction::SetTrue)
.help("Use base credentials instead of session credentials")
)
.arg(
Arg::new("command")
.multiple_values(true)
)
)
}
pub fn show(args: &ArgMatches) -> Result<(), CliError> {
let base = args.get_one("base").unwrap_or(&false);
let creds = get_credentials(*base)?;
println!("{creds}");
Ok(())
}
pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
let base = *args.get_one("base").unwrap_or(&false);
let mut cmd_line = args.get_many("command")
.ok_or(ExecError::NoCommand)?;
let cmd_name: &String = cmd_line.next().unwrap(); // Clap guarantees that there will be at least one
let mut cmd = ChildCommand::new(cmd_name);
cmd.args(cmd_line);
if base {
let creds: BaseCredentials = serde_json::from_str(&get_credentials(base)?)
.map_err(|_| RequestError::InvalidJson)?;
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
}
else {
let creds: SessionCredentials = serde_json::from_str(&get_credentials(base)?)
.map_err(|_| RequestError::InvalidJson)?;
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
cmd.env("AWS_SESSION_TOKEN", creds.token);
}
#[cfg(unix)]
{
// cmd.exec() never returns if successful
let e = cmd.exec();
match e.kind() {
std::io::ErrorKind::NotFound => {
let name: OsString = cmd_name.into();
Err(ExecError::NotFound(name).into())
}
_ => Err(ExecError::ExecutionFailed(e).into()),
}
}
#[cfg(windows)]
{
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let name: OsString = cmd_name.into();
return Err(ExecError::NotFound(name).into());
}
Err(e) => return Err(ExecError::ExecutionFailed(e).into()),
};
let status = child.wait()
.map_err(|e| ExecError::ExecutionFailed(e))?;
std::process::exit(status.code().unwrap_or(1));
};
}
#[tokio::main]
async fn get_credentials(base: bool) -> Result<String, RequestError> {
let pool = app::connect_db().await?;
let config = AppConfig::load(&pool).await?;
let path = if base {"/creddy/base-credentials"} else {"/"};
let mut stream = TcpStream::connect((config.listen_addr, config.listen_port)).await?;
let req = format!("GET {path} HTTP/1.0\r\n\r\n");
stream.write_all(req.as_bytes()).await?;
// some day we'll have a proper HTTP parser
let mut buf = vec![0; 8192];
stream.read_to_end(&mut buf).await?;
let status = buf.split(|&c| &[c] == b" ")
.skip(1)
.next()
.ok_or(RequestError::MalformedHttpResponse)?;
if status != b"200" {
let s = String::from_utf8_lossy(status).to_string();
return Err(RequestError::Failed(s));
}
let break_idx = buf.windows(4)
.position(|w| w == b"\r\n\r\n")
.ok_or(RequestError::MalformedHttpResponse)?;
let body = &buf[(break_idx + 4)..];
let creds_str = std::str::from_utf8(body)
.map_err(|_| RequestError::MalformedHttpResponse)?
.to_string();
if creds_str == "Denied!" {
return Err(RequestError::Rejected);
}
Ok(creds_str)
}

View File

@ -0,0 +1,76 @@
use std::path::PathBuf;
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
use tauri::Manager;
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
use serde::{Serialize, Deserialize};
use crate::{
app::APP,
errors::*,
config::AppConfig,
state::AppState,
};
#[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 item in sockets_iter {
let sock_info = item?;
let proto_info = match sock_info.protocol_socket_info {
ProtocolSocketInfo::Tcp(tcp_info) => tcp_info,
ProtocolSocketInfo::Udp(_) => {continue;}
};
if proto_info.local_port == local_port
&& proto_info.remote_port == app_listen_port
&& proto_info.local_addr == app_listen_addr
&& proto_info.remote_addr == app_listen_addr
{
return Ok(sock_info.associated_pids)
}
}
Ok(vec![])
}
// Theoretically, on some systems, multiple processes can share a socket
pub async fn get_clients(local_port: u16) -> Result<Vec<Option<Client>>, ClientInfoError> {
let mut clients = Vec::new();
let mut sys = System::new();
for p in get_associated_pids(local_port).await? {
let pid = Pid::from_u32(p);
sys.refresh_process(pid);
let proc = sys.process(pid)
.ok_or(ClientInfoError::ProcessNotFound)?;
let client = Client {
pid: p,
exe: proc.exe().to_path_buf(),
};
clients.push(Some(client));
}
if clients.is_empty() {
clients.push(None);
}
Ok(clients)
}

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

@ -0,0 +1,245 @@
use std::net::Ipv4Addr;
use std::path::PathBuf;
use auto_launch::AutoLaunchBuilder;
use is_terminal::IsTerminal;
use serde::{Serialize, Deserialize};
use sqlx::SqlitePool;
use tauri::{
Manager,
GlobalShortcutManager,
async_runtime as rt,
};
use crate::errors::*;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TermConfig {
pub name: String,
// we call it exec because it isn't always the actual path,
// in some cases it's just the name and relies on path-searching
// it's a string because it can come from the frontend as json
pub exec: String,
pub args: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
pub struct Hotkey {
pub keys: String,
pub enabled: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
pub struct HotkeysConfig {
// tauri uses strings to represent keybinds, so we will as well
pub show_window: Hotkey,
pub launch_terminal: Hotkey,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AppConfig {
#[serde(default = "default_listen_addr")]
pub listen_addr: Ipv4Addr,
#[serde(default = "default_listen_port")]
pub listen_port: u16,
#[serde(default = "default_rehide_ms")]
pub rehide_ms: u64,
#[serde(default = "default_start_minimized")]
pub start_minimized: bool,
#[serde(default = "default_start_on_login")]
pub start_on_login: bool,
#[serde(default = "default_term_config")]
pub terminal: TermConfig,
#[serde(default = "default_hotkey_config")]
pub hotkeys: HotkeysConfig,
}
impl Default for AppConfig {
fn default() -> Self {
AppConfig {
listen_addr: default_listen_addr(),
listen_port: default_listen_port(),
rehide_ms: default_rehide_ms(),
start_minimized: default_start_minimized(),
start_on_login: default_start_on_login(),
terminal: default_term_config(),
hotkeys: default_hotkey_config(),
}
}
}
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_term_config() -> TermConfig {
#[cfg(windows)]
{
let shell = if which::which("pwsh.exe").is_ok() {
"pwsh.exe".to_string()
}
else {
"powershell.exe".to_string()
};
let (exec, args) = if cfg!(debug_assertions) {
("conhost.exe".to_string(), vec![shell.clone()])
} else {
(shell.clone(), vec![])
};
TermConfig { name: shell, exec, args }
}
#[cfg(unix)]
{
for bin in ["gnome-terminal", "konsole"] {
if let Ok(_) = which::which(bin) {
return TermConfig {
name: bin.into(),
exec: bin.into(),
args: vec![],
}
}
}
return TermConfig {
name: "gnome-terminal".into(),
exec: "gnome-terminal".into(),
args: vec![],
};
}
}
fn default_hotkey_config() -> HotkeysConfig {
HotkeysConfig {
show_window: Hotkey {keys: "alt+shift+C".into(), enabled: true},
launch_terminal: Hotkey {keys: "alt+shift+T".into(), enabled: true},
}
}
// note: will panic if called before APP is set
pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> tauri::Result<()> {
let app = crate::app::APP.get().unwrap();
let mut manager = app.global_shortcut_manager();
manager.unregister_all()?;
if hotkeys.show_window.enabled {
let handle = app.app_handle();
manager.register(
&hotkeys.show_window.keys,
move || {
handle.get_window("main")
.map(|w| w.show().error_popup("Failed to show"))
.ok_or(HandlerError::NoMainWindow)
.error_popup("No main window");
},
)?;
}
if hotkeys.launch_terminal.enabled {
// register() doesn't take an async fn, so we have to use spawn
manager.register(
&hotkeys.launch_terminal.keys,
|| {
rt::spawn(async {
crate::terminal::launch(false)
.await
.error_popup("Failed to launch");
});
}
)?;
}
Ok(())
}
fn default_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST }
fn default_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,331 @@
use std::fmt::{self, Formatter};
use std::time::{SystemTime, UNIX_EPOCH};
use aws_smithy_types::date_time::{DateTime, Format};
use argon2::{
Argon2,
Algorithm,
Version,
ParamsBuilder,
password_hash::rand_core::{RngCore, OsRng},
};
use chacha20poly1305::{
XChaCha20Poly1305,
XNonce,
aead::{
Aead,
AeadCore,
KeyInit,
Error as AeadError,
generic_array::GenericArray,
},
};
use serde::{
Serialize,
Deserialize,
Serializer,
Deserializer,
};
use serde::de::{self, Visitor};
use sqlx::SqlitePool;
use crate::errors::*;
#[derive(Clone, Debug)]
pub enum Session {
Unlocked{
base: BaseCredentials,
session: SessionCredentials,
},
Locked(LockedCredentials),
Empty,
}
impl Session {
pub async fn load(pool: &SqlitePool) -> Result<Self, SetupError> {
let res = sqlx::query!("SELECT * FROM credentials ORDER BY created_at desc")
.fetch_optional(pool)
.await?;
let row = match res {
Some(r) => r,
None => {return Ok(Session::Empty);}
};
let salt: [u8; 32] = row.salt
.try_into()
.map_err(|_e| SetupError::InvalidRecord)?;
let nonce = XNonce::from_exact_iter(row.nonce.into_iter())
.ok_or(SetupError::InvalidRecord)?;
let creds = LockedCredentials {
access_key_id: row.access_key_id,
secret_key_enc: row.secret_key_enc,
salt,
nonce,
};
Ok(Session::Locked(creds))
}
pub async fn renew_if_expired(&mut self) -> Result<bool, GetSessionError> {
match self {
Session::Unlocked{ref base, ref mut session} => {
if !session.is_expired() {
return Ok(false);
}
*session = SessionCredentials::from_base(base).await?;
Ok(true)
},
Session::Locked(_) => Err(GetSessionError::CredentialsLocked),
Session::Empty => Err(GetSessionError::CredentialsEmpty),
}
}
pub fn try_get(
&self
) -> Result<(&BaseCredentials, &SessionCredentials), GetCredentialsError> {
match self {
Self::Empty => Err(GetCredentialsError::Empty),
Self::Locked(_) => Err(GetCredentialsError::Locked),
Self::Unlocked{ ref base, ref session } => Ok((base, session))
}
}
}
#[derive(Clone, Debug)]
pub struct LockedCredentials {
pub access_key_id: String,
pub secret_key_enc: Vec<u8>,
pub salt: [u8; 32],
pub nonce: XNonce,
}
impl LockedCredentials {
pub async fn save(&self, pool: &SqlitePool) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO credentials (access_key_id, secret_key_enc, salt, nonce, created_at)
VALUES (?, ?, ?, ?, strftime('%s'))"
)
.bind(&self.access_key_id)
.bind(&self.secret_key_enc)
.bind(&self.salt[..])
.bind(&self.nonce[..])
.execute(pool)
.await?;
Ok(())
}
pub fn decrypt(&self, passphrase: &str) -> Result<BaseCredentials, UnlockError> {
let crypto = Crypto::new(passphrase, &self.salt)
.map_err(|e| CryptoError::Argon2(e))?;
let decrypted = crypto.decrypt(&self.nonce, &self.secret_key_enc)
.map_err(|e| CryptoError::Aead(e))?;
let secret_access_key = String::from_utf8(decrypted)
.map_err(|_| UnlockError::InvalidUtf8)?;
let creds = BaseCredentials {
access_key_id: self.access_key_id.clone(),
secret_access_key,
};
Ok(creds)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct BaseCredentials {
pub access_key_id: String,
pub secret_access_key: String,
}
impl BaseCredentials {
pub fn encrypt(&self, passphrase: &str) -> Result<LockedCredentials, CryptoError> {
let salt = Crypto::salt();
let crypto = Crypto::new(passphrase, &salt)?;
let (nonce, secret_key_enc) = crypto.encrypt(self.secret_access_key.as_bytes())?;
let locked = LockedCredentials {
access_key_id: self.access_key_id.clone(),
secret_key_enc,
salt,
nonce,
};
Ok(locked)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct SessionCredentials {
pub access_key_id: String,
pub secret_access_key: String,
pub token: String,
#[serde(serialize_with = "serialize_expiration")]
#[serde(deserialize_with = "deserialize_expiration")]
pub expiration: DateTime,
}
impl SessionCredentials {
pub async fn from_base(base: &BaseCredentials) -> Result<Self, GetSessionError> {
let req_creds = aws_sdk_sts::Credentials::new(
&base.access_key_id,
&base.secret_access_key,
None, // token
None, //expiration
"Creddy", // "provider name" apparently
);
let config = aws_config::from_env()
.credentials_provider(req_creds)
.load()
.await;
let client = aws_sdk_sts::Client::new(&config);
let resp = client.get_session_token()
.duration_seconds(43_200)
.send()
.await?;
let aws_session = resp.credentials().ok_or(GetSessionError::EmptyResponse)?;
let access_key_id = aws_session.access_key_id()
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let secret_access_key = aws_session.secret_access_key()
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let token = aws_session.session_token()
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let expiration = aws_session.expiration()
.ok_or(GetSessionError::EmptyResponse)?
.clone();
let session_creds = SessionCredentials {
access_key_id,
secret_access_key,
token,
expiration,
};
#[cfg(debug_assertions)]
println!("Got new session:\n{}", serde_json::to_string(&session_creds).unwrap());
Ok(session_creds)
}
pub fn is_expired(&self) -> bool {
let current_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap() // doesn't panic because UNIX_EPOCH won't be later than now()
.as_secs();
let expire_ts = self.expiration.secs();
let remaining = expire_ts - (current_ts as i64);
remaining < 60
}
}
fn serialize_expiration<S>(exp: &DateTime, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer
{
// this only fails if the d/t is out of range, which it can't be for this format
let time_str = exp.fmt(Format::DateTime).unwrap();
serializer.serialize_str(&time_str)
}
struct DateTimeVisitor;
impl<'de> Visitor<'de> for DateTimeVisitor {
type Value = DateTime;
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
write!(formatter, "an RFC 3339 UTC string, e.g. \"2014-01-05T10:17:34Z\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<DateTime, E> {
DateTime::from_str(v, Format::DateTime)
.map_err(|_| E::custom(format!("Invalid date/time: {v}")))
}
}
fn deserialize_expiration<'de, D>(deserializer: D) -> Result<DateTime, D::Error>
where D: Deserializer<'de>
{
deserializer.deserialize_str(DateTimeVisitor)
}
struct Crypto {
cipher: XChaCha20Poly1305,
}
impl Crypto {
/// Argon2 params rationale:
///
/// m_cost is measured in KiB, so 128 * 1024 gives us 128MiB.
/// This should roughly double the memory usage of the application
/// while deriving the key.
///
/// p_cost is irrelevant since (at present) there isn't any parallelism
/// implemented, so we leave it at 1.
///
/// With the above m_cost, t_cost = 8 results in about 800ms to derive
/// a key on my (somewhat older) CPU. This is probably overkill, but
/// given that it should only have to happen ~once a day for most
/// usage, it should be acceptable.
#[cfg(not(debug_assertions))]
const MEM_COST: u32 = 128 * 1024;
#[cfg(not(debug_assertions))]
const TIME_COST: u32 = 8;
/// But since this takes a million years without optimizations,
/// we turn it way down in debug builds.
#[cfg(debug_assertions)]
const MEM_COST: u32 = 48 * 1024;
#[cfg(debug_assertions)]
const TIME_COST: u32 = 1;
fn new(passphrase: &str, salt: &[u8]) -> argon2::Result<Crypto> {
let params = ParamsBuilder::new()
.m_cost(Self::MEM_COST)
.p_cost(1)
.t_cost(Self::TIME_COST)
.build()
.unwrap(); // only errors if the given params are invalid
let hasher = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
params,
);
let mut key = [0; 32];
hasher.hash_password_into(passphrase.as_bytes(), &salt, &mut key)?;
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
Ok(Crypto { cipher })
}
fn salt() -> [u8; 32] {
let mut salt = [0; 32];
OsRng.fill_bytes(&mut salt);
salt
}
fn encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec<u8>), AeadError> {
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = self.cipher.encrypt(&nonce, data)?;
Ok((nonce, ciphertext))
}
fn decrypt(&self, nonce: &XNonce, data: &[u8]) -> Result<Vec<u8>, AeadError> {
self.cipher.decrypt(nonce, data)
}
}

390
src-tauri/src/errors.rs Normal file
View File

@ -0,0 +1,390 @@
use std::error::Error;
use std::convert::AsRef;
use std::ffi::OsString;
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);
fn error_popup_nowait(self, title: &str);
}
impl<E: std::fmt::Display> ErrorPopup for Result<(), E> {
fn error_popup(self, title: &str) {
if let Err(e) = self {
let (tx, rx) = mpsc::channel();
MessageDialogBuilder::new(title, format!("{e}"))
.kind(MessageDialogKind::Error)
.show(move |_| tx.send(true).unwrap());
rx.recv().unwrap();
}
}
fn error_popup_nowait(self, title: &str) {
if let Err(e) = self {
MessageDialogBuilder::new(title, format!("{e}"))
.kind(MessageDialogKind::Error)
.show(|_| {})
}
}
}
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 msg = err.source().map(|s| format!("{s}"));
map.serialize_entry("msg", &msg)?;
map.serialize_entry("code", &None::<&str>)?;
map.serialize_entry("source", &None::<&str>)?;
Ok(())
}
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("Failed to start listener: {0}")]
ServerSetupError(#[from] std::io::Error),
#[error("Failed to resolve data directory: {0}")]
DataDir(#[from] DataDirError),
#[error("Failed to register hotkeys: {0}")]
RegisterHotkeys(#[from] tauri::Error),
}
#[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 {
#[error("The specified credentials request was not found")]
NotFound,
#[error("The specified request was already closed by the client")]
Abandoned,
#[error("A response has already been received for the specified request")]
Fulfilled,
#[error("Could not renew AWS sesssion: {0}")]
SessionRenew(#[from] GetSessionError),
}
// errors encountered while handling an HTTP request
#[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,
#[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,
#[error(transparent)]
Crypto(#[from] CryptoError),
#[error("Data was found to be corrupt after decryption")]
InvalidUtf8, // Somehow we got invalid utf-8 even though decryption succeeded
#[error("Database error: {0}")]
DbError(#[from] SqlxError),
#[error("Failed to create AWS session: {0}")]
GetSession(#[from] GetSessionError),
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum CryptoError {
#[error(transparent)]
Argon2(#[from] argon2::Error),
#[error("Invalid passphrase")] // I think this is the only way decryption fails
Aead(#[from] chacha20poly1305::aead::Error),
}
// Errors encountered while trying to figure out who's on the other end of a request
#[derive(Debug, ThisError, AsRefStr)]
pub enum ClientInfoError {
#[error("Found PID for client socket, but no corresponding process")]
ProcessNotFound,
#[error("Couldn't get client socket details: {0}")]
NetstatError(#[from] netstat2::error::Error),
}
// Errors encountered while requesting credentials via CLI (creddy show, creddy exec)
#[derive(Debug, ThisError, AsRefStr)]
pub enum RequestError {
#[error("Credentials request failed: HTTP {0}")]
Failed(String),
#[error("Credentials request was rejected")]
Rejected,
#[error("Couldn't interpret the server's response")]
MalformedHttpResponse,
#[error("The server did not respond with valid JSON")]
InvalidJson,
#[error("Error reading/writing stream: {0}")]
StreamIOError(#[from] std::io::Error),
#[error("Error loading configuration data: {0}")]
Setup(#[from] SetupError),
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum CliError {
#[error(transparent)]
Request(#[from] RequestError),
#[error(transparent)]
Exec(#[from] ExecError),
#[error(transparent)]
Io(#[from] std::io::Error),
}
// Errors encountered while trying to launch a child process
#[derive(Debug, ThisError, AsRefStr)]
pub enum ExecError {
#[error("Please specify a command")]
NoCommand,
#[error("Executable not found: {0:?}")]
NotFound(OsString),
#[error("Failed to execute command: {0}")]
ExecutionFailed(#[from] std::io::Error),
#[error(transparent)]
GetCredentials(#[from] GetCredentialsError),
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum LaunchTerminalError {
#[error("Could not discover main window")]
NoMainWindow,
#[error("Failed to communicate with main Creddy window")]
IpcFailed(#[from] tauri::Error),
#[error("Failed to launch terminal: {0}")]
Exec(#[from] ExecError),
#[error(transparent)]
GetCredentials(#[from] GetCredentialsError),
}
// =========================
// Serialize implementations
// =========================
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()
}
}
impl Serialize for ExecError {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("code", self.as_ref())?;
map.serialize_entry("msg", &format!("{self}"))?;
match self {
ExecError::GetCredentials(src) => map.serialize_entry("source", &src)?,
_ => serialize_upstream_err(self, &mut map)?,
}
map.end()
}
}
impl Serialize for LaunchTerminalError {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("code", self.as_ref())?;
map.serialize_entry("msg", &format!("{self}"))?;
match self {
LaunchTerminalError::Exec(src) => map.serialize_entry("source", &src)?,
_ => serialize_upstream_err(self, &mut map)?,
}
map.end()
}
}

View File

@ -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"),
}
}
}

View File

@ -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()))
}

93
src-tauri/src/ipc.rs Normal file
View File

@ -0,0 +1,93 @@
use serde::{Serialize, Deserialize};
use tauri::State;
use crate::config::AppConfig;
use crate::credentials::{Session,BaseCredentials};
use crate::errors::*;
use crate::clientinfo::Client;
use crate::state::AppState;
use crate::terminal;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Request {
pub id: u64,
pub clients: Vec<Option<Client>>,
pub base: bool,
}
#[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 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<(), UnlockError> {
app_state.unlock(&passphrase).await
}
#[tauri::command]
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::Empty => "empty".into()
};
Ok(status)
}
#[tauri::command]
pub async fn save_credentials(
credentials: BaseCredentials,
passphrase: String,
app_state: State<'_, AppState>
) -> 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(())
}
#[tauri::command]
pub async fn launch_terminal(base: bool) -> Result<(), LaunchTerminalError> {
terminal::launch(base).await
}
#[tauri::command]
pub async fn get_setup_errors(app_state: State<'_, AppState>) -> Result<Vec<String>, ()> {
Ok(app_state.setup_errors.clone())
}

11
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,11 @@
pub mod app;
pub mod cli;
mod config;
mod credentials;
pub mod errors;
mod clientinfo;
mod ipc;
mod state;
mod server;
mod terminal;
mod tray;

View File

@ -1,30 +1,27 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use std::str::FromStr;
// use tokio::runtime::Runtime;
mod storage;
mod http;
use creddy::{
app,
cli,
errors::ErrorPopup,
};
fn main() {
tauri::Builder::default()
.setup(|app| {
let addr = std::net::SocketAddrV4::from_str("127.0.0.1:12345").unwrap();
tauri::async_runtime::spawn(http::serve(addr, app.handle()));
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
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!(),
};
// 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");
if let Err(e) = res {
eprintln!("Error: {e}");
}
}

275
src-tauri/src/server.rs Normal file
View File

@ -0,0 +1,275 @@
use core::time::Duration;
use std::io;
use std::net::{
Ipv4Addr,
SocketAddr,
SocketAddrV4,
};
use tokio::net::{
TcpListener,
TcpStream,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot::{self, Sender, Receiver};
use tokio::time::sleep;
use tauri::{AppHandle, Manager};
use tauri::async_runtime as rt;
use tauri::async_runtime::JoinHandle;
use crate::{clientinfo, clientinfo::Client};
use crate::errors::*;
use crate::ipc::{Request, Approval};
use crate::state::AppState;
#[derive(Debug)]
pub struct RequestWaiter {
pub rehide_after: bool,
pub sender: Option<Sender<Approval>>,
}
impl RequestWaiter {
pub fn notify(&mut self, approval: Approval) -> Result<(), SendResponseError> {
let chan = self.sender
.take()
.ok_or(SendResponseError::Fulfilled)?;
chan.send(approval)
.map_err(|_| SendResponseError::Abandoned)
}
}
struct Handler {
request_id: u64,
stream: TcpStream,
rehide_after: bool,
receiver: Option<Receiver<Approval>>,
app: AppHandle,
}
impl Handler {
async fn new(stream: TcpStream, app: AppHandle) -> Result<Self, HandlerError> {
let state = app.state::<AppState>();
// determine whether we should re-hide the window after handling this request
let is_currently_visible = app.get_window("main")
.ok_or(HandlerError::NoMainWindow)?
.is_visible()?;
let rehide_after = state.current_rehide_status()
.await
.unwrap_or(!is_currently_visible);
let (chan_send, chan_recv) = oneshot::channel();
let waiter = RequestWaiter {rehide_after, sender: Some(chan_send)};
let request_id = state.register_request(waiter).await;
let handler = Handler {
request_id,
stream,
rehide_after,
receiver: Some(chan_recv),
app
};
Ok(handler)
}
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)?;
self.show_window()?;
match self.wait_for_response().await? {
Approval::Approved => {
let state = self.app.state::<AppState>();
let creds = if base {
state.serialize_base_creds().await?
}
else {
state.serialize_session_creds().await?
};
self.send_body(creds.as_bytes()).await?;
},
Approval::Denied => {
let state = self.app.state::<AppState>();
for client in req.clients {
state.add_ban(client).await;
}
self.send_body(b"Denied!").await?;
self.stream.shutdown().await?;
}
}
// only hide the window if a) it was hidden to start with
// and b) there are no other pending requests
let state = self.app.state::<AppState>();
let delay = {
let config = state.config.read().await;
Duration::from_millis(config.rehide_ms)
};
sleep(delay).await;
if self.rehide_after && state.req_count().await == 1 {
self.app
.get_window("main")
.ok_or(HandlerError::NoMainWindow)?
.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<(), HandlerError> {
let window = self.app.get_window("main").ok_or(HandlerError::NoMainWindow)?;
if !window.is_visible()? {
window.unminimize()?;
window.show()?;
}
window.set_focus()?;
Ok(())
}
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, _)) => {
match Handler::new(stream, app_handle.app_handle()).await {
Ok(handler) => { rt::spawn(handler.handle()); }
Err(e) => { eprintln!("Error handling request: {e}"); }
}
},
Err(e) => { eprintln!("Error accepting connection: {e}"); }
}
}
}
}

206
src-tauri/src/state.rs Normal file
View File

@ -0,0 +1,206 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use tokio::{
sync::RwLock,
time::sleep,
};
use sqlx::SqlitePool;
use tauri::async_runtime as runtime;
use tauri::Manager;
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, RequestWaiter};
#[derive(Debug)]
pub struct AppState {
pub config: RwLock<AppConfig>,
pub session: RwLock<Session>,
pub request_count: RwLock<u64>,
pub waiting_requests: RwLock<HashMap<u64, RequestWaiter>>,
pub pending_terminal_request: RwLock<bool>,
pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
// setup_errors is never modified and so doesn't need to be wrapped in RwLock
pub setup_errors: Vec<String>,
server: RwLock<Server>,
pool: sqlx::SqlitePool,
}
impl AppState {
pub fn new(
config: AppConfig,
session: Session,
server: Server,
pool: SqlitePool,
setup_errors: Vec<String>,
) -> AppState {
AppState {
config: RwLock::new(config),
session: RwLock::new(session),
request_count: RwLock::new(0),
waiting_requests: RwLock::new(HashMap::new()),
pending_terminal_request: RwLock::new(false),
bans: RwLock::new(HashSet::new()),
setup_errors,
server: RwLock::new(server),
pool,
}
}
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 async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
let mut live_config = self.config.write().await;
// update autostart if necessary
if new_config.start_on_login != live_config.start_on_login {
config::set_auto_launch(new_config.start_on_login)?;
}
// rebind socket if necessary
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?;
}
// re-register hotkeys if necessary
if new_config.hotkeys.show_window != live_config.hotkeys.show_window
|| new_config.hotkeys.launch_terminal != live_config.hotkeys.launch_terminal
{
config::register_hotkeys(&new_config.hotkeys)?;
}
new_config.save(&self.pool).await?;
*live_config = new_config;
Ok(())
}
pub async fn register_request(&self, waiter: RequestWaiter) -> u64 {
let count = {
let mut c = self.request_count.write().await;
*c += 1;
c
};
let mut waiting_requests = self.waiting_requests.write().await;
waiting_requests.insert(*count, waiter); // `count` is the request id
*count
}
pub async fn unregister_request(&self, id: u64) {
let mut waiting_requests = self.waiting_requests.write().await;
waiting_requests.remove(&id);
}
pub async fn req_count(&self) -> usize {
let waiting_requests = self.waiting_requests.read().await;
waiting_requests.len()
}
pub async fn current_rehide_status(&self) -> Option<bool> {
// since all requests that are pending at a given time should have the same
// value for rehide_after, it doesn't matter which one we use
let waiting_requests = self.waiting_requests.read().await;
waiting_requests.iter().next().map(|(_id, w)| w.rehide_after)
}
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
if let Approval::Approved = response.approval {
let mut session = self.session.write().await;
session.renew_if_expired().await?;
}
let mut waiting_requests = self.waiting_requests.write().await;
waiting_requests
.get_mut(&response.id)
.ok_or(SendResponseError::NotFound)?
.notify(response.approval)
}
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 locked) => locked.decrypt(passphrase)?,
};
// Read lock is dropped here, so this doesn't deadlock
self.new_session(base_creds).await?;
Ok(())
}
pub async fn is_unlocked(&self) -> bool {
let session = self.session.read().await;
matches!(*session, Session::Unlocked{..})
}
pub async fn serialize_base_creds(&self) -> Result<String, GetCredentialsError> {
let app_session = self.session.read().await;
let (base, _session) = app_session.try_get()?;
Ok(serde_json::to_string(base).unwrap())
}
pub async fn serialize_session_creds(&self) -> Result<String, GetCredentialsError> {
let app_session = self.session.read().await;
let (_bsae, session) = app_session.try_get()?;
Ok(serde_json::to_string(session).unwrap())
}
async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> {
let session = SessionCredentials::from_base(&base).await?;
let mut app_session = self.session.write().await;
*app_session = Session::Unlocked {base, session};
Ok(())
}
pub async fn register_terminal_request(&self) -> Result<(), ()> {
let mut req = self.pending_terminal_request.write().await;
if *req {
// if a request is already pending, we can't register a new one
Err(())
}
else {
*req = true;
Ok(())
}
}
pub async fn unregister_terminal_request(&self) {
let mut req = self.pending_terminal_request.write().await;
*req = false;
}
}

View File

@ -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")
}

82
src-tauri/src/terminal.rs Normal file
View File

@ -0,0 +1,82 @@
use std::process::Command;
use tauri::Manager;
use crate::app::APP;
use crate::errors::*;
use crate::state::AppState;
pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> {
let app = APP.get().unwrap();
let state = app.state::<AppState>();
// register_terminal_request() returns Err if there is another request pending
if state.register_terminal_request().await.is_err() {
return Ok(());
}
let mut cmd = {
let config = state.config.read().await;
let mut cmd = Command::new(&config.terminal.exec);
cmd.args(&config.terminal.args);
cmd
};
// if session is unlocked or empty, wait for credentials from frontend
if !state.is_unlocked().await {
app.emit_all("launch-terminal-request", ())?;
let window = app.get_window("main")
.ok_or(LaunchTerminalError::NoMainWindow)?;
if !window.is_visible()? {
window.unminimize()?;
window.show()?;
}
window.set_focus()?;
let (tx, rx) = tokio::sync::oneshot::channel();
app.once_global("credentials-event", move |e| {
let success = match e.payload() {
Some("\"unlocked\"") | Some("\"entered\"") => true,
_ => false,
};
let _ = tx.send(success);
});
if !rx.await.unwrap_or(false) {
state.unregister_terminal_request().await;
return Ok(()); // request was canceled by user
}
}
// more lock-management
{
let app_session = state.session.read().await;
// session should really be unlocked at this point, but if the frontend misbehaves
// (i.e. lies about unlocking) we could end up here with a locked session
// this will result in an error popup to the user (see main hotkey handler)
let (base_creds, session_creds) = app_session.try_get()?;
if use_base {
cmd.env("AWS_ACCESS_KEY_ID", &base_creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", &base_creds.secret_access_key);
}
else {
cmd.env("AWS_ACCESS_KEY_ID", &session_creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", &session_creds.secret_access_key);
cmd.env("AWS_SESSION_TOKEN", &session_creds.token);
}
}
let res = match cmd.spawn() {
Ok(_) => Ok(()),
Err(e) if std::io::ErrorKind::NotFound == e.kind() => {
Err(ExecError::NotFound(cmd.get_program().to_owned()))
},
Err(e) => Err(ExecError::ExecutionFailed(e)),
};
state.unregister_terminal_request().await;
res?; // ? auto-conversion is more liberal than .into()
Ok(())
}

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,12 @@
},
"package": {
"productName": "creddy",
"version": "0.1.0"
"version": "0.3.3"
},
"tauri": {
"allowlist": {
"all": true
"os": {"all": true},
"dialog": {"open": true}
},
"bundle": {
"active": true,
@ -29,7 +30,7 @@
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.tauri.dev",
"identifier": "creddy",
"longDescription": "",
"macOS": {
"entitlements": null,
@ -44,11 +45,18 @@
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
"timestampUrl": "",
"wix": {
"fragmentPaths": ["conf/cli.wxs"],
"componentRefs": ["CliBinary", "AddToPath"]
}
}
},
"security": {
"csp": null
"csp": {
"default-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"]
}
},
"updater": {
"active": false
@ -58,9 +66,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,13 +1,42 @@
<script>
import { emit, listen } from '@tauri-apps/api/event';
import Home from './views/Home.svelte';
import Approve from './views/Approve.svelte';
import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
let activeComponent = Home;
import { appState, acceptRequest } 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);
});
listen('launch-terminal-request', async (tauriEvent) => {
if ($appState.currentRequest === null) {
let status = await invoke('get_session_status');
if (status === 'locked') {
navigate('Unlock');
}
else if (status === 'empty') {
navigate('EnterCredentials');
}
// else, session is unlocked, so do nothing
// (although we shouldn't even get the event in that case)
}
});
invoke('get_setup_errors')
.then(errs => {
$appState.setupErrors = errs.map(e => ({msg: e, show: true}));
});
acceptRequest();
</script>
<svelte:component this={activeComponent} />
<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;
}
}

View File

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

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)
}

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

@ -0,0 +1,34 @@
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',
setupErrors: [],
});
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} />

13
src/ui/KeyCombo.svelte Normal file
View File

@ -0,0 +1,13 @@
<script>
export let keys;
</script>
<div class="flex gap-x-[0.2em] items-center">
{#each keys as key, i}
{#if i > 0}
<span class="mt-[-0.1em]">+</span>
{/if}
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">{key}</kbd>
{/each}
</div>

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>

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

@ -0,0 +1,42 @@
<script>
export let thickness = 8;
let classes = '';
export { classes as class };
const radius = (100 - thickness) / 2;
// the px are fake, but we need them to satisfy css calc()
const circumference = `${2 * Math.PI * radius}px`;
</script>
<svg
style:--circumference={circumference}
class={classes}
viewBox="0 0 100 100"
stroke="currentColor"
>
<circle cx="50" cy="50" r={radius} stroke-width={thickness} />
</svg>
<style>
circle {
fill: transparent;
stroke-dasharray: var(--circumference);
transform: rotate(-90deg);
transform-origin: center;
animation: chase 3s infinite,
spin 1.5s linear infinite;
}
@keyframes chase {
0% { stroke-dashoffset: calc(-1 * var(--circumference)); }
50% { stroke-dashoffset: calc(-2 * var(--circumference)); }
100% { stroke-dashoffset: calc(-3 * var(--circumference)); }
}
@keyframes spin {
50% { transform: rotate(135deg); }
100% { transform: rotate(270deg); }
}
</style>

View File

@ -0,0 +1,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,27 @@
<script>
import { createEventDispatcher } from 'svelte';
import { open } from '@tauri-apps/api/dialog';
import Setting from './Setting.svelte';
export let title;
export let value;
const dispatch = createEventDispatcher();
</script>
<Setting {title}>
<div slot="input">
<input
type="text"
class="input input-sm input-bordered grow text-right"
bind:value
on:change={() => dispatch('update', {value})}
>
<button
class="btn btn-sm btn-primary"
on:click={async () => value = await open()}
>Browse</button>
</div>
<slot name="description" slot="description"></slot>
</Setting>

View File

@ -0,0 +1,72 @@
<script>
import { createEventDispatcher } from 'svelte';
import KeyCombo from '../KeyCombo.svelte';
export let description;
export let value;
const id = Math.random().toString().slice(2);
const dispatch = createEventDispatcher();
const MODIFIERS = new Set(['Alt', 'AltGraph', 'Control', 'Fn', 'FnLock', 'Meta', 'Shift', 'Super', ]);
let listening = false;
let keysPressed = [];
function addModifiers(event) {
// add modifier key if it isn't already present
if (MODIFIERS.has(event.key) && keysPressed.indexOf(event.key) === -1) {
keysPressed.push(event.key);
}
}
function addMainKey(event) {
if (!MODIFIERS.has(event.key)) {
keysPressed.push(event.key);
value.keys = keysPressed.join('+');
dispatch('update', {value});
event.preventDefault();
event.stopPropagation();
unlisten();
}
}
function listen() {
// don't re-listen if we already are
if (listening) return;
listening = true;
window.addEventListener('keydown', addModifiers);
window.addEventListener('keyup', addMainKey);
// setTimeout avoids reacting to the click event that we are currently processing
setTimeout(() => window.addEventListener('click', unlisten), 0);
}
function unlisten() {
listening = false;
keysPressed = [];
window.removeEventListener('keydown', addModifiers);
window.removeEventListener('keyup', addMainKey);
window.removeEventListener('click', unlisten);
}
</script>
<input
{id}
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={value.enabled}
on:change={() => dispatch('update', {value})}
>
<label for={id} class="cursor-pointer ml-4 text-lg">{description}</label>
<button class="h-12 p-2 rounded border border-neutral cursor-pointer text-center" on:click={listen}>
{#if listening}
Click to cancel
{:else}
<KeyCombo keys={value.keys.split('+')} />
{/if}
</button>

View File

@ -0,0 +1,81 @@
<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,22 @@
<script>
import { slide } from 'svelte/transition';
import ErrorAlert from '../ErrorAlert.svelte';
export let title;
</script>
<div>
<div class="flex flex-wrap justify-between gap-y-4">
<h3 class="text-lg font-bold shrink-0">{title}</h3>
{#if $$slots.input}
<slot name="input"></slot>
{/if}
</div>
{#if $$slots.description}
<p class="mt-3">
<slot name="description"></slot>
</p>
{/if}
</div>

View File

@ -0,0 +1,14 @@
<script>
export let name;
</script>
<div>
<div class="divider mt-0 mb-8">
<h2 class="text-xl font-bold">{name}</h2>
</div>
<div class="space-y-12">
<slot></slot>
</div>
</div>

View File

@ -0,0 +1,22 @@
<script>
import { createEventDispatcher } from 'svelte';
import Setting from './Setting.svelte';
export let title;
export let value;
const dispatch = createEventDispatcher();
</script>
<Setting {title}>
<div slot="input">
<input
type="text"
class="input input-sm input-bordered grow text-right"
bind:value
on:change={() => dispatch('update', {value})}
>
</div>
<slot name="description" slot="description"></slot>
</Setting>

View File

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

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

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

View File

@ -1,19 +1,125 @@
<script>
import { createEventDispatcher } from 'svelte';
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri';
const dispatch = createEventDispatcher();
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';
import KeyCombo from '../ui/KeyCombo.svelte';
// 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') {
await respond();
}
else if (status === 'locked') {
navigate('Unlock');
}
else {
navigate('EnterCredentials');
}
}
// 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={() => 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>
<!-- 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={() => 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>
{#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">
<span class="mr-2">Deny</span>
<KeyCombo keys={['Esc']} />
</button>
</Link>
<Link target={approve} hotkey="Enter" shift="{true}">
<button class="btn btn-success justify-self-end">
<span class="mr-2">Approve</span>
<KeyCombo keys={['Shift', 'Enter']} />
</button>
</Link>
</div>
</div>
{/if}

View File

@ -0,0 +1,91 @@
<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';
import Spinner from '../ui/Spinner.svelte';
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 {
saving = true;
await invoke('save_credentials', {credentials, passphrase});
emit('credentials-event', 'entered');
if ($appState.currentRequest) {
navigate('Approve');
}
else {
navigate('Home');
}
}
catch (e) {
window.error = e;
const root = getRootCause(e);
if (e.code === 'GetSession' && root.code) {
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
}
else {
errorMsg = e.msg;
}
// if the alert already existed, shake it
if (alert) {
alert.shake();
}
saving = false;
}
}
function cancel() {
emit('credentials-event', 'enter-canceled');
navigate('Home');
}
</script>
<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} />
<button type="submit" class="btn btn-primary">
{#if saving }
<Spinner class="w-5 h-5" thickness="12"/>
{:else}
Submit
{/if}
</button>
<Link target={cancel} hotkey="Escape">
<button class="btn btn-sm btn-outline w-full">Cancel</button>
</Link>
</form>

View File

@ -1 +1,67 @@
<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';
import vaultDoorSvg from '../assets/vault_door.svg?raw';
let launchBase = false;
function launchTerminal() {
invoke('launch_terminal', {base: launchBase});
launchBase = false;
}
</script>
<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">
<div class="flex flex-col items-center space-y-4">
{@html vaultDoorSvg}
{#await invoke('get_session_status') then status}
{#if status === 'locked'}
<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'}
<h2 class="text-2xl font-bold">Waiting for requests</h2>
<button class="btn btn-primary w-full" on:click={launchTerminal}>
Launch Terminal
</button>
<label class="label cursor-pointer flex items-center space-x-2">
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}>
<span class="label-text">Launch with base credentials</span>
</label>
{:else if status === 'empty'}
<h2 class="text-2xl font-bold">No credentials found</h2>
<Link target="EnterCredentials" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Enter Credentials</button>
</Link>
{/if}
{/await}
</div>
</div>
{#if $appState.setupErrors.some(e => e.show)}
<div class="toast">
{#each $appState.setupErrors as error}
{#if error.show}
<div class="alert alert-error shadow-lg">
{error.msg}
<button class="btn btn-sm btn-alert-error" on:click={() => error.show = false}>Ok</button>
</div>
{/if}
{/each}
</div>
{/if}

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

@ -0,0 +1,119 @@
<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 SettingsGroup from '../ui/settings/SettingsGroup.svelte';
import Keybind from '../ui/settings/Keybind.svelte';
import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting } from '../ui/settings';
import { fly } from 'svelte/transition';
import { backInOut } from 'svelte/easing';
let error = null;
async function save() {
console.log('updating config');
try {
await invoke('save_config', {config: $appState.config});
}
catch (e) {
error = e;
$appState.config = await invoke('get_config');
}
}
let osType = null;
type().then(t => osType = t);
</script>
<Nav>
<h1 slot="title" class="text-2xl font-bold">Settings</h1>
</Nav>
{#await invoke('get_config') then config}
<div class="max-w-lg mx-auto mt-1.5 p-4 space-y-16">
<SettingsGroup name="General">
<ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}>
<svelte:fragment slot="description">
Start Creddy when you log in to your computer.
</svelte:fragment>
</ToggleSetting>
<ToggleSetting title="Start 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>
<FileSetting
title="Terminal emulator"
bind:value={$appState.config.terminal.exec}
on:update={save}
>
<svelte:fragment slot="description">
Choose your preferred terminal emulator (e.g. <code>gnome-terminal</code> or <code>wt.exe</code>.) May be an absolute path or an executable discoverable on <code>$PATH</code>.
</svelte:fragment>
</FileSetting>
</SettingsGroup>
<SettingsGroup name="Hotkeys">
<div class="space-y-4">
<p>Click on a keybinding to modify it. Use the checkbox to enable or disable a keybinding entirely.</p>
<div class="grid grid-cols-[auto_1fr_auto] gap-y-3 items-center">
<Keybind description="Show Creddy" value={$appState.config.hotkeys.show_window} on:update={save} />
<Keybind description="Launch terminal" value={$appState.config.hotkeys.launch_terminal} on:update={save} />
</div>
</div>
</SettingsGroup>
</div>
{/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

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

88
src/views/Unlock.svelte Normal file
View File

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

View File

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