Compare commits
108 Commits
dev2
...
0491cb5790
Author | SHA1 | Date | |
---|---|---|---|
0491cb5790 | |||
816bd7db00 | |||
b165965289 | |||
86896d68c2 | |||
64a2927b94 | |||
87617a0726 | |||
141334f7e2 | |||
69f6a39396 | |||
70e23c7e20 | |||
1df849442e | |||
7fdb336c79 | |||
46b8d810c5 | |||
dd40eb379e | |||
13545ac725 | |||
040a01536a | |||
4e2a90b15b | |||
e0d919ed4a | |||
3f4efc5f8f | |||
4881b90b0b | |||
1b749a857c | |||
2079f99d04 | |||
5e0ffc1155 | |||
d4fa8966b2 | |||
a293d8f92c | |||
367a140e2a | |||
4b06dce7f4 | |||
47a3e1cfef | |||
1047818fdc | |||
3d093a3a45 | |||
992d2a4d06 | |||
12f0f187a6 | |||
997e8b419f | |||
1d9132de3b | |||
e1c2618dc8 | |||
a7df7adc8e | |||
03d164c9d3 | |||
f522674a1c | |||
51fcccafa2 | |||
e3913ab4c9 | |||
c16f21bba3 | |||
61d9acc7c6 | |||
8d7b01629d | |||
5685948608 | |||
c98a065587 | |||
e46c3d2b4d | |||
fa228acc3a | |||
e7e0f9d33e | |||
a51b20add7 | |||
890f715388 | |||
89bc74e644 | |||
60c24e3ee4 | |||
486001b584 | |||
52c949e396 | |||
d7c5c2f37b | |||
ae5b8f31db | |||
c260e37e78 | |||
7501253970 | |||
5b9c711008 | |||
ddd1005067 | |||
e866a4a643 | |||
94400ba7d5 | |||
616600687d | |||
e8b8dc2976 | |||
ddf865d0b4 | |||
96bbc2dbc2 | |||
161148d1f6 | |||
760987f09b | |||
a75f34865e | |||
886fcd9bb8 | |||
55775b6b05 | |||
871dedf0a3 | |||
913148a75a | |||
e746963052 | |||
b761d3b493 | |||
c5dcc2e50a | |||
70d71ce14e | |||
33a5600a30 | |||
741169d807 | |||
ebc00a5df6 | |||
c2cc007a81 | |||
4aab08e6f0 | |||
12d9d733a5 | |||
35271049dd | |||
6f9cd6b471 | |||
865b7fd5c4 | |||
f35352eedd | |||
53580d7919 | |||
049b81610d | |||
fd60899f16 | |||
e0c4c849dc | |||
cb26201506 | |||
992e3c8db2 | |||
4956b64371 | |||
df6b362a31 | |||
2943634248 | |||
06f5a1af42 | |||
61d674199f | |||
398916fe10 | |||
bf4c46238e | |||
5ffa55c03c | |||
50f0985f4f | |||
69475604c0 | |||
856b6f1e1b | |||
414379b74e | |||
80b92ebe69 | |||
983d0e8639 | |||
d77437cda8 | |||
3d5cbedae1 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -2,6 +2,10 @@ dist
|
||||
**/node_modules
|
||||
src-tauri/target/
|
||||
**/creddy.db
|
||||
# .env is system-specific
|
||||
.env
|
||||
.vscode
|
||||
|
||||
# just in case
|
||||
credentials*
|
||||
!credentials.rs
|
||||
|
9
doc/cryptography.md
Normal file
9
doc/cryptography.md
Normal file
@ -0,0 +1,9 @@
|
||||
My original plan was to use [libsodium](https://doc.libsodium.org/) to handle encryption. However, the Rust bindings for libsodium are no longer actively maintained, which left me uncomfortable with using it. Instead, I switched to the [RustCrypto](https://github.com/RustCrypto) implementations of the same (or nearly the same) cryptographic primitives provided by libsodium.
|
||||
|
||||
Creddy makes use of two cryptographic primitives: A key-derivation function, which is currently `argon2id`, and a symmetric encryption algorithm, currently `XChaCha20Poly1305`.
|
||||
* I chose `argon2id` because it's what libsodium uses, and because its difficulty parameters admit of very granular tuning.
|
||||
* I chose `XChaCha20Poly1305` because it's _almost_ what libsodium uses - libsodium uses `XSalsa20Poly1305`, and it's my undersatnding that `XChaCha20Poly1305` is an evolution of the former. In both cases I use the eXtended variants, which make use of longer (24-byte) nonces than the non-X variants. This appealed to me because I wanted to be able to randomly generate a nonce every time I needed one, and I have seen [recommendations](https://latacora.micro.blog/2018/04/03/cryptographic-right-answers.html) that the 12-byte nonces used by the non-X variants are _juuust_ a touch small for that to be truly worry-free. The RustCrypto implementation of `XChaCha20Poly1305` has also been subject to a security audit, which is nice.
|
||||
|
||||
I tuned the `argon2id` parameters so that key-derivation would take ~800ms on my Ryzen 1600X. This is probably overkill, but I don't intend for key-derivation to be a frequent occurrence - no more than once a day, under normal circumstances. Taking in the neighborhood of 1 second seemed about the longest I could reasonably go.
|
||||
|
||||
**DISCLAIMER**: I am not a professional cryptographer, merely an interested amateur. While I've tried to be as careful as possible with selecting and making use of the cryptographic building blocks I've chosen here, there is always the possibility that I've screwed something up. If anyone would like to sponsor an _actual_ security review of Creddy by people who _actually_ know what they're doing instead of just what they've read on the internet, please let me know.
|
@ -9,7 +9,7 @@ The following is a list of security features that I hope to add eventually, in a
|
||||
* 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.)
|
||||
* "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. Obviously this would require the current user having the ability to revoke their own IAM permissions.)
|
||||
* 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.)
|
||||
@ -19,9 +19,9 @@ Another possible approach is to _watch_ the files in question, and alert the use
|
||||
|
||||
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 basic idea behind Creddy is that it provides "gap coverage" between two wildly different security models: 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.
|
||||
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 (along with the frequently-replaced nature of mobile devices) 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.
|
||||
|
||||
@ -31,13 +31,13 @@ There are lots of ways that I can imagine someone might try to circumvent Creddy
|
||||
|
||||
### 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.
|
||||
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 Creddy would pop up requesting then permission, and then immediately disappear again because the request had been approved. 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.
|
||||
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. The attacker could inject the credentials into the environment before running the original command, so as to avoid alerting the user by issuing a second credentials request.
|
||||
|
||||
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.
|
||||
Another attack along the same lines 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
|
||||
|
||||
@ -46,3 +46,5 @@ Most desktop environments don't prevent applications from simulating user-input
|
||||
### 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.
|
||||
|
||||
The solution to this problem is probably just to encrypt the entire database. This introduces a bit of complexity since certain settings, like `start_on_login` and `start_minimized`, will need to be accessible before the app is unlocked,but these settings can probably just be stashed alongside the database and kept in sync on every config save.
|
||||
|
25
doc/todo.md
Normal file
25
doc/todo.md
Normal file
@ -0,0 +1,25 @@
|
||||
## Definitely
|
||||
|
||||
* ~~Switch to "process" provider for AWS credentials (much less hacky)~~
|
||||
* ~~Frontend needs to react when request is cancelled from backend~~
|
||||
* ~~Session timeout~~
|
||||
* ~~Fix rehide behavior when new request comes in while old one is still being resolved~~
|
||||
* ~~Switch tray menu item to Hide when window is visible~~
|
||||
* Clear password input after unlock fails
|
||||
* Indicate on approval screen when additional requests are pending
|
||||
* Additional hotkey configuration (approve/deny at the very least)
|
||||
* Logging
|
||||
* Icon
|
||||
* Auto-updates
|
||||
* SSH key handling
|
||||
* Encrypted sync server
|
||||
|
||||
## Maybe
|
||||
|
||||
* Flatten error type hierarchy
|
||||
* Rehide after terminal launch from locked
|
||||
* Generalize Request across both credentials and terminal launch?
|
||||
* Make hotkey configuration a little more tolerant of slight mistiming
|
||||
* Distinguish between request that was denied and request that was canceled (e.g. due to error)
|
||||
* Use atomic types for primitive state values instead of RwLock'd types
|
||||
* Rework approval flow to be a fullscreen overlay instead of mixing with normal navigation (as more views are added the pain of the current situation will only increase)
|
16
index.html
16
index.html
@ -1,25 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Svelte</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body class="bg-zinc-800">
|
||||
<div id="app"></div>
|
||||
<body id="app" class="m-0">
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
2473
package-lock.json
generated
2473
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "creddy",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.9",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@ -9,7 +9,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^1.0.1",
|
||||
"@tauri-apps/cli": "^1.0.5",
|
||||
"@tauri-apps/cli": "^2.0.0-beta.20",
|
||||
"autoprefixer": "^10.4.8",
|
||||
"postcss": "^8.4.16",
|
||||
"svelte": "^3.49.0",
|
||||
@ -17,6 +17,9 @@
|
||||
"vite": "^3.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^1.0.2"
|
||||
"@tauri-apps/api": "^2.0.0-beta.13",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0-beta.5",
|
||||
"@tauri-apps/plugin-os": "^2.0.0-beta.5",
|
||||
"daisyui": "^2.51.5"
|
||||
}
|
||||
}
|
||||
|
7
src-tauri/.cargo/config.toml
Normal file
7
src-tauri/.cargo/config.toml
Normal file
@ -0,0 +1,7 @@
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=--ld-path=/usr/bin/mold"]
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
|
||||
|
4621
src-tauri/Cargo.lock
generated
4621
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,33 +1,63 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
name = "creddy"
|
||||
version = "0.4.9"
|
||||
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]
|
||||
tauri-build = { version = "1.0.4", features = [] }
|
||||
tauri-build = { version = "2.0.0-beta", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.0.5", features = ["api-all"] }
|
||||
tauri = { version = "2.0.0-beta", features = ["tray-icon"] }
|
||||
sodiumoxide = "0.2.7"
|
||||
tokio = { version = ">=1.19", features = ["full"] }
|
||||
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] }
|
||||
netstat2 = "0.9.1"
|
||||
sysinfo = "0.26.8"
|
||||
aws-types = "0.52.0"
|
||||
aws-sdk-sts = "0.22.0"
|
||||
aws-smithy-types = "0.52.0"
|
||||
aws-config = "0.52.0"
|
||||
thiserror = "1.0.38"
|
||||
once_cell = "1.16.0"
|
||||
strum = "0.24"
|
||||
strum_macros = "0.24"
|
||||
auto-launch = "0.4.0"
|
||||
dirs = "5.0"
|
||||
clap = { version = "3.2.23", features = ["derive"] }
|
||||
is-terminal = "0.4.7"
|
||||
argon2 = { version = "0.5.0", features = ["std"] }
|
||||
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
|
||||
which = "4.4.0"
|
||||
windows = { version = "0.51.1", features = ["Win32_Foundation", "Win32_System_Pipes"] }
|
||||
time = "0.3.31"
|
||||
tauri-plugin-single-instance = "2.0.0-beta.9"
|
||||
tauri-plugin-global-shortcut = "2.0.0-beta.6"
|
||||
rfd = "0.14.1"
|
||||
|
||||
[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
|
||||
|
17
src-tauri/capabilities/migrated.json
Normal file
17
src-tauri/capabilities/migrated.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"identifier": "migrated",
|
||||
"description": "permissions that were migrated from v1",
|
||||
"local": true,
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"path:default",
|
||||
"event:default",
|
||||
"window:default",
|
||||
"app:default",
|
||||
"resources:default",
|
||||
"menu:default",
|
||||
"tray:default"
|
||||
]
|
||||
}
|
22
src-tauri/conf/cli.wxs
Normal file
22
src-tauri/conf/cli.wxs
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
|
||||
<Fragment>
|
||||
|
||||
<DirectoryRef Id="INSTALLDIR">
|
||||
<!-- Create a subdirectory for the console binary so that we can add it to PATH -->
|
||||
<Directory Id="BinDir" Name="bin">
|
||||
<Component Id="CliBinary" Guid="b6358c8e-504f-41fd-b14b-38af821dcd04">
|
||||
<!-- Same name as the main executable, so that it can be invoked as just "creddy" -->
|
||||
<File Id="Bin_Cli" Source="..\..\creddy_cli.exe" Name="creddy.exe" KeyPath="yes"/>
|
||||
</Component>
|
||||
</Directory>
|
||||
</DirectoryRef>
|
||||
|
||||
<DirectoryRef Id="TARGETDIR">
|
||||
<Component Id="AddToPath" Guid="b5fdaf7e-94f2-4aad-9144-aa3a8edfa675">
|
||||
<Environment Id="CreddyInstallDir" Action="set" Name="PATH" Part="last" Permanent="no" Value="[BinDir]" />
|
||||
</Component>
|
||||
</DirectoryRef>
|
||||
|
||||
</Fragment>
|
||||
</Wix>
|
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@ -0,0 +1 @@
|
||||
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default"]}}
|
2243
src-tauri/gen/schemas/desktop-schema.json
Normal file
2243
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2243
src-tauri/gen/schemas/linux-schema.json
Normal file
2243
src-tauri/gen/schemas/linux-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -3,12 +3,13 @@ CREATE TABLE credentials (
|
||||
access_key_id TEXT NOT NULL,
|
||||
secret_key_enc BLOB NOT NULL,
|
||||
salt BLOB NOT NULL,
|
||||
nonce BLOB NOT NULL
|
||||
nonce BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE config (
|
||||
name TEXT,
|
||||
data TEXT
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE clients (
|
||||
|
188
src-tauri/src/app.rs
Normal file
188
src-tauri/src/app.rs
Normal file
@ -0,0 +1,188 @@
|
||||
use std::error::Error;
|
||||
use std::time::Duration;
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use rfd::{
|
||||
MessageDialog,
|
||||
MessageLevel,
|
||||
};
|
||||
use sqlx::{
|
||||
SqlitePool,
|
||||
sqlite::SqlitePoolOptions,
|
||||
sqlite::SqliteConnectOptions,
|
||||
};
|
||||
use tauri::{
|
||||
App,
|
||||
AppHandle,
|
||||
async_runtime as rt,
|
||||
Manager,
|
||||
RunEvent,
|
||||
WindowEvent,
|
||||
};
|
||||
use tauri::menu::MenuItem;
|
||||
|
||||
use crate::{
|
||||
config::{self, AppConfig},
|
||||
credentials::Session,
|
||||
ipc,
|
||||
server::Server,
|
||||
errors::*,
|
||||
shortcuts,
|
||||
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| {
|
||||
show_main_window(app)
|
||||
.error_popup("Failed to show main window")
|
||||
}))
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::default().build())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
ipc::unlock,
|
||||
ipc::respond,
|
||||
ipc::get_session_status,
|
||||
ipc::signal_activity,
|
||||
ipc::save_credentials,
|
||||
ipc::get_config,
|
||||
ipc::save_config,
|
||||
ipc::launch_terminal,
|
||||
ipc::get_setup_errors,
|
||||
])
|
||||
.setup(|app| {
|
||||
let res = rt::block_on(setup(app));
|
||||
if let Err(ref e) = res {
|
||||
MessageDialog::new()
|
||||
.set_level(MessageLevel::Error)
|
||||
.set_title("Creddy failed to start")
|
||||
.set_description(format!("{e}"))
|
||||
.show();
|
||||
}
|
||||
res
|
||||
})
|
||||
.build(tauri::generate_context!())?
|
||||
.run(|app, run_event| {
|
||||
if let RunEvent::WindowEvent { event, .. } = run_event {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
let _ = hide_main_window(app);
|
||||
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().clone()).unwrap();
|
||||
tray::setup(app)?;
|
||||
// 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 mut 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?;
|
||||
Server::start(app.handle().clone())?;
|
||||
|
||||
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 hotkeys fail to register, disable them so that this error doesn't have to keep showing up
|
||||
if let Err(_e) = shortcuts::register_hotkeys(&conf.hotkeys) {
|
||||
conf.hotkeys.disable_all();
|
||||
conf.save(&pool).await?;
|
||||
setup_errors.push("Failed to register hotkeys. Hotkey settings have been disabled.".into());
|
||||
}
|
||||
|
||||
let desktop_is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.map(|names| names.split(':').any(|n| n == "GNOME"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// if session is empty, this is probably the first launch, so don't autohide
|
||||
if !conf.start_minimized || is_first_launch {
|
||||
show_main_window(&app.handle())?;
|
||||
}
|
||||
|
||||
let state = AppState::new(conf, session, pool, setup_errors, desktop_is_gnome);
|
||||
app.manage(state);
|
||||
|
||||
// make sure we do this after managing app state, so that it doesn't panic
|
||||
start_auto_locker(app.app_handle().clone());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn start_auto_locker(app: AppHandle) {
|
||||
rt::spawn(async move {
|
||||
let state = app.state::<AppState>();
|
||||
loop {
|
||||
// this gives our session-timeout a minimum resolution of 10s, which seems fine?
|
||||
let delay = Duration::from_secs(10);
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
if state.should_auto_lock().await {
|
||||
state.lock().await.error_popup("Failed to lock Creddy");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
pub fn show_main_window(app: &AppHandle) -> Result<(), WindowError> {
|
||||
let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?;
|
||||
w.show()?;
|
||||
let show_hide = app.state::<MenuItem<tauri::Wry>>();
|
||||
show_hide.set_text("Hide")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
pub fn hide_main_window(app: &AppHandle) -> Result<(), WindowError> {
|
||||
let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?;
|
||||
w.hide()?;
|
||||
let show_hide = app.state::<MenuItem<tauri::Wry>>();
|
||||
show_hide.set_text("Show")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
pub fn toggle_main_window(app: &AppHandle) -> Result<(), WindowError> {
|
||||
let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?;
|
||||
if w.is_visible()? {
|
||||
hide_main_window(app)
|
||||
}
|
||||
else {
|
||||
show_main_window(app)
|
||||
}
|
||||
}
|
47
src-tauri/src/bin/creddy_cli.rs
Normal file
47
src-tauri/src/bin/creddy_cli.rs
Normal file
@ -0,0 +1,47 @@
|
||||
// Windows isn't really amenable to having a single executable work as both a CLI and GUI app,
|
||||
// so we just have a second binary for CLI usage
|
||||
use creddy::{
|
||||
cli,
|
||||
errors::CliError,
|
||||
};
|
||||
use std::{
|
||||
env,
|
||||
process::{self, Command},
|
||||
};
|
||||
|
||||
|
||||
fn main() {
|
||||
let args = cli::parser().get_matches();
|
||||
if let Some(true) = args.get_one::<bool>("help") {
|
||||
cli::parser().print_help().unwrap(); // if we can't print help we can't print an error
|
||||
process::exit(0);
|
||||
}
|
||||
|
||||
let res = match args.subcommand() {
|
||||
None | Some(("run", _)) => launch_gui(),
|
||||
Some(("get", m)) => cli::get(m),
|
||||
Some(("exec", m)) => cli::exec(m),
|
||||
Some(("shortcut", m)) => cli::invoke_shortcut(m),
|
||||
_ => unreachable!("Unknown subcommand"),
|
||||
};
|
||||
|
||||
if let Err(e) = res {
|
||||
eprintln!("Error: {e}");
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn launch_gui() -> Result<(), CliError> {
|
||||
let mut path = env::current_exe()?;
|
||||
path.pop(); // bin dir
|
||||
|
||||
// binaries are colocated in dev, but not in production
|
||||
#[cfg(not(debug_assertions))]
|
||||
path.pop(); // install dir
|
||||
|
||||
path.push("creddy.exe"); // exe in main install dir (aka gui exe)
|
||||
|
||||
Command::new(path).spawn()?;
|
||||
Ok(())
|
||||
}
|
203
src-tauri/src/cli.rs
Normal file
203
src-tauri/src/cli.rs
Normal file
@ -0,0 +1,203 @@
|
||||
use std::ffi::OsString;
|
||||
use std::process::Command as ChildCommand;
|
||||
#[cfg(windows)]
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::{
|
||||
Command,
|
||||
Arg,
|
||||
ArgMatches,
|
||||
ArgAction,
|
||||
builder::PossibleValuesParser,
|
||||
};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use crate::credentials::Credentials;
|
||||
use crate::errors::*;
|
||||
use crate::server::{Request, Response};
|
||||
use crate::shortcuts::ShortcutAction;
|
||||
|
||||
#[cfg(unix)]
|
||||
use {
|
||||
std::os::unix::process::CommandExt,
|
||||
tokio::net::UnixStream,
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
use {
|
||||
tokio::net::windows::named_pipe::{NamedPipeClient, ClientOptions},
|
||||
windows::Win32::Foundation::ERROR_PIPE_BUSY,
|
||||
};
|
||||
|
||||
|
||||
pub fn parser() -> Command<'static> {
|
||||
Command::new("creddy")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.about("A friendly AWS credentials manager")
|
||||
.subcommand(
|
||||
Command::new("run")
|
||||
.about("Launch Creddy")
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("get")
|
||||
.about("Request AWS credentials from Creddy and output to stdout")
|
||||
.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)
|
||||
)
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("shortcut")
|
||||
.about("Invoke an action normally trigged by hotkey (e.g. launch terminal)")
|
||||
.arg(
|
||||
Arg::new("action")
|
||||
.value_parser(
|
||||
PossibleValuesParser::new(["show_window", "launch_terminal"])
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
pub fn get(args: &ArgMatches) -> Result<(), CliError> {
|
||||
let base = args.get_one("base").unwrap_or(&false);
|
||||
let output = match get_credentials(*base)? {
|
||||
Credentials::Base(creds) => serde_json::to_string(&creds).unwrap(),
|
||||
Credentials::Session(creds) => serde_json::to_string(&creds).unwrap(),
|
||||
};
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
|
||||
match get_credentials(base)? {
|
||||
Credentials::Base(creds) => {
|
||||
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
||||
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
||||
},
|
||||
Credentials::Session(creds) => {
|
||||
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
||||
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
||||
cmd.env("AWS_SESSION_TOKEN", creds.session_token);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// cmd.exec() 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));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
pub fn invoke_shortcut(args: &ArgMatches) -> Result<(), CliError> {
|
||||
let action = match args.get_one::<String>("action").map(|s| s.as_str()) {
|
||||
Some("show_window") => ShortcutAction::ShowWindow,
|
||||
Some("launch_terminal") => ShortcutAction::LaunchTerminal,
|
||||
Some(&_) | None => unreachable!("Unknown shortcut action"), // guaranteed by clap
|
||||
};
|
||||
|
||||
let req = Request::InvokeShortcut(action);
|
||||
match make_request(&req) {
|
||||
Ok(Response::Empty) => Ok(()),
|
||||
Ok(r) => Err(RequestError::Unexpected(r).into()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn get_credentials(base: bool) -> Result<Credentials, RequestError> {
|
||||
let req = Request::GetAwsCredentials { base };
|
||||
match make_request(&req) {
|
||||
Ok(Response::Aws(creds)) => Ok(creds),
|
||||
Ok(r) => Err(RequestError::Unexpected(r)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[tokio::main]
|
||||
async fn make_request(req: &Request) -> Result<Response, RequestError> {
|
||||
let mut data = serde_json::to_string(req).unwrap();
|
||||
// server expects newline marking end of request
|
||||
data.push('\n');
|
||||
|
||||
let mut stream = connect().await?;
|
||||
stream.write_all(&data.as_bytes()).await?;
|
||||
|
||||
let mut buf = Vec::with_capacity(1024);
|
||||
stream.read_to_end(&mut buf).await?;
|
||||
let res: Result<Response, ServerError> = serde_json::from_slice(&buf)?;
|
||||
Ok(res?)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn connect() -> Result<NamedPipeClient, std::io::Error> {
|
||||
// apparently attempting to connect can fail if there's already a client connected
|
||||
loop {
|
||||
match ClientOptions::new().open(r"\\.\pipe\creddy-requests") {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => (),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn connect() -> Result<UnixStream, std::io::Error> {
|
||||
UnixStream::connect("/tmp/creddy.sock").await
|
||||
}
|
@ -1,51 +1,35 @@
|
||||
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
|
||||
use sysinfo::{System, SystemExt, Pid, ProcessExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use crate::errors::*;
|
||||
use crate::ipc::Client;
|
||||
|
||||
|
||||
fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
|
||||
let mut it = netstat2::iterate_sockets_info(
|
||||
AddressFamilyFlags::IPV4,
|
||||
ProtocolFlags::TCP
|
||||
)?;
|
||||
|
||||
for (i, item) in it.enumerate() {
|
||||
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 == 12345
|
||||
&& proto_info.local_addr == std::net::Ipv4Addr::LOCALHOST
|
||||
&& proto_info.remote_addr == std::net::Ipv4Addr::LOCALHOST
|
||||
{
|
||||
return Ok(sock_info.associated_pids)
|
||||
}
|
||||
}
|
||||
Ok(vec![])
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||
pub struct Client {
|
||||
pub pid: u32,
|
||||
pub exe: Option<PathBuf>,
|
||||
}
|
||||
|
||||
|
||||
// Theoretically, on some systems, multiple processes can share a socket. We have to
|
||||
// account for this even though 99% of the time there will be only one.
|
||||
pub fn get_clients(local_port: u16) -> Result<Vec<Client>, ClientInfoError> {
|
||||
let mut clients = Vec::new();
|
||||
let mut sys = System::new();
|
||||
for p in get_associated_pids(local_port)? {
|
||||
let pid = Pid::from(p as usize);
|
||||
sys.refresh_process(pid);
|
||||
let proc = sys.process(pid)
|
||||
.ok_or(ClientInfoError::PidNotFound)?;
|
||||
pub fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> {
|
||||
let sys_pid = Pid::from_u32(pid);
|
||||
let mut sys = System::new();
|
||||
sys.refresh_process(sys_pid);
|
||||
let proc = sys.process(sys_pid)
|
||||
.ok_or(ClientInfoError::ProcessNotFound)?;
|
||||
|
||||
let client = Client {
|
||||
pid: p,
|
||||
exe: proc.exe().to_string_lossy().into_owned(),
|
||||
};
|
||||
clients.push(client);
|
||||
}
|
||||
Ok(clients)
|
||||
let parent_pid_sys = proc.parent()
|
||||
.ok_or(ClientInfoError::ParentPidNotFound)?;
|
||||
sys.refresh_process(parent_pid_sys);
|
||||
let parent = sys.process(parent_pid_sys)
|
||||
.ok_or(ClientInfoError::ParentProcessNotFound)?;
|
||||
|
||||
let exe = match parent.exe() {
|
||||
p if p == Path::new("") => None,
|
||||
p => Some(PathBuf::from(p)),
|
||||
};
|
||||
|
||||
Ok(Client { pid: parent_pid_sys.as_u32(), exe })
|
||||
}
|
||||
|
223
src-tauri/src/config.rs
Normal file
223
src-tauri/src/config.rs
Normal file
@ -0,0 +1,223 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use auto_launch::AutoLaunchBuilder;
|
||||
use is_terminal::IsTerminal;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::errors::*;
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct 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,
|
||||
}
|
||||
|
||||
impl HotkeysConfig {
|
||||
pub fn disable_all(&mut self) {
|
||||
self.show_window.enabled = false;
|
||||
self.launch_terminal.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default = "default_rehide_ms")]
|
||||
pub rehide_ms: u64,
|
||||
#[serde(default = "default_auto_lock")]
|
||||
pub auto_lock: bool,
|
||||
#[serde(default = "default_lock_after")]
|
||||
pub lock_after: Duration,
|
||||
#[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 {
|
||||
rehide_ms: default_rehide_ms(),
|
||||
auto_lock: default_auto_lock(),
|
||||
lock_after: default_lock_after(),
|
||||
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_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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn default_rehide_ms() -> u64 { 1000 }
|
||||
fn default_auto_lock() -> bool { true }
|
||||
fn default_lock_after() -> Duration { Duration::from_secs(43200) }
|
||||
// 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) }
|
||||
|
||||
|
||||
// struct DurationVisitor;
|
||||
|
||||
// impl<'de> Visitor<'de> for DurationVisitor {
|
||||
// type Value = Duration;
|
||||
|
||||
// fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
|
||||
// write!(formatter, "an integer between 0 and 2^64 - 1")
|
||||
// }
|
||||
|
||||
// fn visit_u64<E: de::Error>(self, v: u64) -> Result<Duration, E> {
|
||||
// Ok(Duration::from_secs(v))
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
// fn duration_from_secs<'de, D>(deserializer: D) -> Result<Duration, D::Error>
|
||||
// where D: Deserializer<'de>
|
||||
// {
|
||||
// deserializer.deserialize_u64(DurationVisitor)
|
||||
// }
|
350
src-tauri/src/credentials.rs
Normal file
350
src-tauri/src/credentials.rs
Normal file
@ -0,0 +1,350 @@
|
||||
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::new(
|
||||
self.access_key_id.clone(),
|
||||
secret_access_key,
|
||||
);
|
||||
Ok(creds)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn default_credentials_version() -> usize { 1 }
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct BaseCredentials {
|
||||
#[serde(default = "default_credentials_version")]
|
||||
pub version: usize,
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: String,
|
||||
}
|
||||
|
||||
impl BaseCredentials {
|
||||
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
|
||||
Self {version: 1, access_key_id, secret_access_key}
|
||||
}
|
||||
|
||||
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 {
|
||||
#[serde(default = "default_credentials_version")]
|
||||
pub version: usize,
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: String,
|
||||
pub session_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 session_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 {
|
||||
version: 1,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
session_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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum Credentials {
|
||||
Base(BaseCredentials),
|
||||
Session(SessionCredentials),
|
||||
}
|
||||
|
||||
|
||||
fn serialize_expiration<S>(exp: &DateTime, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where S: Serializer
|
||||
{
|
||||
// this only fails if the d/t is out of range, which it can't be for this format
|
||||
let time_str = exp.fmt(Format::DateTime).unwrap();
|
||||
serializer.serialize_str(&time_str)
|
||||
}
|
||||
|
||||
|
||||
struct DateTimeVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for DateTimeVisitor {
|
||||
type Value = DateTime;
|
||||
|
||||
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
|
||||
write!(formatter, "an RFC 3339 UTC string, e.g. \"2014-01-05T10:17:34Z\"")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<DateTime, E> {
|
||||
DateTime::from_str(v, Format::DateTime)
|
||||
.map_err(|_| E::custom(format!("Invalid date/time: {v}")))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn deserialize_expiration<'de, D>(deserializer: D) -> Result<DateTime, D::Error>
|
||||
where D: Deserializer<'de>
|
||||
{
|
||||
deserializer.deserialize_str(DateTimeVisitor)
|
||||
}
|
||||
|
||||
|
||||
struct Crypto {
|
||||
cipher: XChaCha20Poly1305,
|
||||
}
|
||||
|
||||
impl Crypto {
|
||||
/// Argon2 params rationale:
|
||||
///
|
||||
/// m_cost is measured in KiB, so 128 * 1024 gives us 128MiB.
|
||||
/// This should roughly double the memory usage of the application
|
||||
/// while deriving the key.
|
||||
///
|
||||
/// p_cost is irrelevant since (at present) there isn't any parallelism
|
||||
/// implemented, so we leave it at 1.
|
||||
///
|
||||
/// With the above m_cost, t_cost = 8 results in about 800ms to derive
|
||||
/// a key on my (somewhat older) CPU. This is probably overkill, but
|
||||
/// given that it should only have to happen ~once a day for most
|
||||
/// usage, it should be acceptable.
|
||||
#[cfg(not(debug_assertions))]
|
||||
const MEM_COST: u32 = 128 * 1024;
|
||||
#[cfg(not(debug_assertions))]
|
||||
const TIME_COST: u32 = 8;
|
||||
|
||||
/// But since this takes a million years without optimizations,
|
||||
/// we turn it way down in debug builds.
|
||||
#[cfg(debug_assertions)]
|
||||
const MEM_COST: u32 = 48 * 1024;
|
||||
#[cfg(debug_assertions)]
|
||||
const TIME_COST: u32 = 1;
|
||||
|
||||
|
||||
fn new(passphrase: &str, salt: &[u8]) -> argon2::Result<Crypto> {
|
||||
let params = ParamsBuilder::new()
|
||||
.m_cost(Self::MEM_COST)
|
||||
.p_cost(1)
|
||||
.t_cost(Self::TIME_COST)
|
||||
.build()
|
||||
.unwrap(); // only errors if the given params are invalid
|
||||
|
||||
let hasher = Argon2::new(
|
||||
Algorithm::Argon2id,
|
||||
Version::V0x13,
|
||||
params,
|
||||
);
|
||||
|
||||
let mut key = [0; 32];
|
||||
hasher.hash_password_into(passphrase.as_bytes(), &salt, &mut key)?;
|
||||
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
|
||||
Ok(Crypto { cipher })
|
||||
}
|
||||
|
||||
fn salt() -> [u8; 32] {
|
||||
let mut salt = [0; 32];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
salt
|
||||
}
|
||||
|
||||
fn encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec<u8>), AeadError> {
|
||||
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
|
||||
let ciphertext = self.cipher.encrypt(&nonce, data)?;
|
||||
Ok((nonce, ciphertext))
|
||||
}
|
||||
|
||||
fn decrypt(&self, nonce: &XNonce, data: &[u8]) -> Result<Vec<u8>, AeadError> {
|
||||
self.cipher.decrypt(nonce, data)
|
||||
}
|
||||
}
|
@ -1,144 +1,471 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::convert::From;
|
||||
use std::str::Utf8Error;
|
||||
use std::error::Error;
|
||||
use std::convert::AsRef;
|
||||
use std::ffi::OsString;
|
||||
use std::string::FromUtf8Error;
|
||||
use strum_macros::AsRefStr;
|
||||
|
||||
use thiserror::Error as ThisError;
|
||||
use aws_sdk_sts::{
|
||||
types::SdkError as AwsSdkError,
|
||||
error::GetSessionTokenError,
|
||||
};
|
||||
use rfd::{
|
||||
AsyncMessageDialog,
|
||||
MessageLevel,
|
||||
};
|
||||
use sqlx::{
|
||||
error::Error as SqlxError,
|
||||
migrate::MigrateError,
|
||||
};
|
||||
use tauri::async_runtime as rt;
|
||||
use tauri_plugin_global_shortcut::Error as ShortcutError;
|
||||
use tokio::sync::oneshot::error::RecvError;
|
||||
use serde::{
|
||||
Serialize,
|
||||
Serializer,
|
||||
ser::SerializeMap,
|
||||
Deserialize,
|
||||
};
|
||||
|
||||
|
||||
pub trait ShowError<T, E>
|
||||
{
|
||||
fn error_popup(self, title: &str);
|
||||
fn error_print(self);
|
||||
fn error_print_prefix(self, prefix: &str);
|
||||
}
|
||||
|
||||
impl<T, E> ShowError<T, E> for Result<T, E>
|
||||
where E: std::fmt::Display
|
||||
{
|
||||
fn error_popup(self, title: &str) {
|
||||
if let Err(e) = self {
|
||||
let dialog = AsyncMessageDialog::new()
|
||||
.set_level(MessageLevel::Error)
|
||||
.set_title(title)
|
||||
.set_description(format!("{e}"));
|
||||
rt::spawn(async move {dialog.show().await});
|
||||
}
|
||||
}
|
||||
|
||||
fn error_print(self) {
|
||||
if let Err(e) = self {
|
||||
eprintln!("{e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn error_print_prefix(self, prefix: &str) {
|
||||
if let Err(e) = self {
|
||||
eprintln!("{prefix}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
struct SerializeUpstream<E>(pub E);
|
||||
|
||||
impl<E: Error> Serialize for SerializeUpstream<E> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let msg = format!("{}", self.0);
|
||||
let mut map = serializer.serialize_map(None)?;
|
||||
map.serialize_entry("msg", &msg)?;
|
||||
map.serialize_entry("code", &None::<&str>)?;
|
||||
map.serialize_entry("source", &None::<&str>)?;
|
||||
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>)?;
|
||||
|
||||
match err.source() {
|
||||
Some(src) => map.serialize_entry("source", &SerializeUpstream(src))?,
|
||||
None => 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
|
||||
DbError(SqlxError),
|
||||
}
|
||||
impl From<SqlxError> for SetupError {
|
||||
fn from(e: SqlxError) -> SetupError {
|
||||
SetupError::DbError(e)
|
||||
}
|
||||
}
|
||||
impl From<MigrateError> for SetupError {
|
||||
fn from (e: MigrateError) -> SetupError {
|
||||
SetupError::DbError(SqlxError::from(e))
|
||||
}
|
||||
}
|
||||
impl Display for SetupError {
|
||||
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
|
||||
match self {
|
||||
SetupError::InvalidRecord => write!(f, "Malformed database record"),
|
||||
SetupError::DbError(e) => write!(f, "Error from database: {e}"),
|
||||
}
|
||||
}
|
||||
#[error("Error from database: {0}")]
|
||||
DbError(#[from] SqlxError),
|
||||
#[error("Error running migrations: {0}")]
|
||||
MigrationError(#[from] MigrateError),
|
||||
#[error("Error parsing configuration from database")]
|
||||
ConfigParseError(#[from] serde_json::Error),
|
||||
#[error("Failed to set up start-on-login: {0}")]
|
||||
AutoLaunchError(#[from] auto_launch::Error),
|
||||
#[error("Failed to start listener: {0}")]
|
||||
ServerSetupError(#[from] std::io::Error),
|
||||
#[error("Failed to resolve data directory: {0}")]
|
||||
DataDir(#[from] DataDirError),
|
||||
#[error("Failed to register hotkeys: {0}")]
|
||||
RegisterHotkeys(#[from] ShortcutError),
|
||||
}
|
||||
|
||||
|
||||
// error when attempting to tell a request handler whether to release or deny crednetials
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum DataDirError {
|
||||
#[error("Could not determine data directory")]
|
||||
NotFound,
|
||||
#[error("Failed to create data directory: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
|
||||
// error when attempting to tell a request handler whether to release or deny credentials
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum SendResponseError {
|
||||
NotFound, // no request with the given id
|
||||
Abandoned, // request has already been closed by client
|
||||
}
|
||||
|
||||
impl Display for SendResponseError {
|
||||
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
|
||||
use SendResponseError::*;
|
||||
match self {
|
||||
NotFound => write!(f, "The specified command was not found."),
|
||||
Abandoned => write!(f, "The specified request was closed by the client."),
|
||||
}
|
||||
}
|
||||
#[error("The specified credentials request was not found")]
|
||||
NotFound,
|
||||
#[error("The specified request was already closed by the client")]
|
||||
Abandoned,
|
||||
#[error("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
|
||||
pub enum RequestError {
|
||||
StreamIOError(std::io::Error),
|
||||
InvalidUtf8,
|
||||
MalformedHttpRequest,
|
||||
// errors encountered while handling a client 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(#[from] FromUtf8Error),
|
||||
#[error("HTTP request malformed")]
|
||||
BadRequest(#[from] serde_json::Error),
|
||||
#[error("HTTP request too large")]
|
||||
RequestTooLarge,
|
||||
NoCredentials(GetCredentialsError),
|
||||
ClientInfo(ClientInfoError),
|
||||
}
|
||||
impl From<tokio::io::Error> for RequestError {
|
||||
fn from(e: std::io::Error) -> RequestError {
|
||||
RequestError::StreamIOError(e)
|
||||
}
|
||||
}
|
||||
impl From<Utf8Error> for RequestError {
|
||||
fn from(_e: Utf8Error) -> RequestError {
|
||||
RequestError::InvalidUtf8
|
||||
}
|
||||
}
|
||||
impl From<GetCredentialsError> for RequestError {
|
||||
fn from (e: GetCredentialsError) -> RequestError {
|
||||
RequestError::NoCredentials(e)
|
||||
}
|
||||
}
|
||||
impl From<ClientInfoError> for RequestError {
|
||||
fn from(e: ClientInfoError) -> RequestError {
|
||||
RequestError::ClientInfo(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RequestError {
|
||||
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
|
||||
use RequestError::*;
|
||||
match self {
|
||||
StreamIOError(e) => write!(f, "Stream IO error: {e}"),
|
||||
InvalidUtf8 => write!(f, "Could not decode UTF-8 from bytestream"),
|
||||
MalformedHttpRequest => write!(f, "Maformed HTTP request"),
|
||||
RequestTooLarge => write!(f, "HTTP request too large"),
|
||||
NoCredentials(GetCredentialsError::Locked) => write!(f, "Recieved go-ahead but app is locked"),
|
||||
NoCredentials(GetCredentialsError::Empty) => write!(f, "Received go-ahead but no credentials are known"),
|
||||
ClientInfo(ClientInfoError::PidNotFound) => write!(f, "Could not resolve PID of client process."),
|
||||
ClientInfo(ClientInfoError::NetstatError(e)) => write!(f, "Error getting client socket details: {e}"),
|
||||
}
|
||||
}
|
||||
#[error("Connection closed early by client")]
|
||||
Abandoned,
|
||||
#[error("Internal server error")]
|
||||
Internal(#[from] RecvError),
|
||||
#[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,
|
||||
#[error("Request was denied")]
|
||||
Denied,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum WindowError {
|
||||
#[error("Failed to find main application window")]
|
||||
NoMainWindow,
|
||||
#[error(transparent)]
|
||||
ManageFailure(#[from] tauri::Error),
|
||||
}
|
||||
|
||||
|
||||
#[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 construct session: credentials are locked")]
|
||||
CredentialsLocked,
|
||||
#[error("Could not construct session: no credentials are known")]
|
||||
CredentialsEmpty,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum UnlockError {
|
||||
#[error("App is not locked")]
|
||||
NotLocked,
|
||||
#[error("No saved credentials were found")]
|
||||
NoCredentials,
|
||||
BadPassphrase,
|
||||
#[error(transparent)]
|
||||
Crypto(#[from] CryptoError),
|
||||
#[error("Data was found to be corrupt after decryption")]
|
||||
InvalidUtf8, // Somehow we got invalid utf-8 even though decryption succeeded
|
||||
DbError(SqlxError),
|
||||
#[error("Database error: {0}")]
|
||||
DbError(#[from] SqlxError),
|
||||
#[error("Failed to create AWS session: {0}")]
|
||||
GetSession(#[from] GetSessionError),
|
||||
}
|
||||
impl From<SqlxError> for UnlockError {
|
||||
fn from (e: SqlxError) -> UnlockError {
|
||||
match e {
|
||||
SqlxError::RowNotFound => UnlockError::NoCredentials,
|
||||
_ => UnlockError::DbError(e),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum LockError {
|
||||
#[error("App is not unlocked")]
|
||||
NotUnlocked,
|
||||
#[error("Database error: {0}")]
|
||||
DbError(#[from] SqlxError),
|
||||
#[error(transparent)]
|
||||
Setup(#[from] SetupError),
|
||||
#[error(transparent)]
|
||||
TauriError(#[from] tauri::Error),
|
||||
}
|
||||
impl Display for UnlockError {
|
||||
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
|
||||
use UnlockError::*;
|
||||
match self {
|
||||
NotLocked => write!(f, "App is not locked"),
|
||||
NoCredentials => write!(f, "No saved credentials were found"),
|
||||
BadPassphrase => write!(f, "Invalid passphrase"),
|
||||
InvalidUtf8 => write!(f, "Decrypted data was corrupted"),
|
||||
DbError(e) => write!(f, "Database error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum CryptoError {
|
||||
#[error(transparent)]
|
||||
Argon2(#[from] argon2::Error),
|
||||
#[error("Invalid passphrase")] // I think this is the only way decryption fails
|
||||
Aead(#[from] chacha20poly1305::aead::Error),
|
||||
}
|
||||
|
||||
|
||||
// Errors encountered while trying to figure out who's on the other end of a request
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum ClientInfoError {
|
||||
PidNotFound,
|
||||
NetstatError(netstat2::error::Error),
|
||||
#[error("Found PID for client socket, but no corresponding process")]
|
||||
ProcessNotFound,
|
||||
#[error("Could not determine parent PID of connected client")]
|
||||
ParentPidNotFound,
|
||||
#[error("Found PID for parent process of client, but no corresponding process")]
|
||||
ParentProcessNotFound,
|
||||
#[cfg(windows)]
|
||||
#[error("Could not determine PID of connected client")]
|
||||
WindowsError(#[from] windows::core::Error),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
impl From<netstat2::error::Error> for ClientInfoError {
|
||||
fn from(e: netstat2::error::Error) -> ClientInfoError {
|
||||
ClientInfoError::NetstatError(e)
|
||||
|
||||
|
||||
// Technically also an error, but formatted as a struct for easy deserialization
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ServerError {
|
||||
code: String,
|
||||
msg: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ServerError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
|
||||
write!(f, "{} ({})", self.msg, self.code)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Errors encountered while requesting credentials via CLI (creddy show, creddy exec)
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum RequestError {
|
||||
#[error("Error response from server: {0}")]
|
||||
Server(ServerError),
|
||||
#[error("Unexpected response from server")]
|
||||
Unexpected(crate::server::Response),
|
||||
#[error("The server did not respond with valid JSON")]
|
||||
InvalidJson(#[from] serde_json::Error),
|
||||
#[error("Error reading/writing stream: {0}")]
|
||||
StreamIOError(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl From<ServerError> for RequestError {
|
||||
fn from(s: ServerError) -> Self {
|
||||
Self::Server(s)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[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_basic!(WindowError);
|
||||
impl_serialize_basic!(LockError);
|
||||
|
||||
|
||||
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}"))?;
|
||||
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)?,
|
||||
// The string representation of the AEAD error is not very helpful, so skip it
|
||||
UnlockError::Crypto(_src) => map.serialize_entry("source", &None::<&str>)?,
|
||||
_ => serialize_upstream_err(self, &mut map)?,
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Serialize for ExecError {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut map = serializer.serialize_map(None)?;
|
||||
map.serialize_entry("code", self.as_ref())?;
|
||||
map.serialize_entry("msg", &format!("{self}"))?;
|
||||
|
||||
match self {
|
||||
ExecError::GetCredentials(src) => map.serialize_entry("source", &src)?,
|
||||
_ => serialize_upstream_err(self, &mut map)?,
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Serialize for LaunchTerminalError {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut map = serializer.serialize_map(None)?;
|
||||
map.serialize_entry("code", self.as_ref())?;
|
||||
map.serialize_entry("msg", &format!("{self}"))?;
|
||||
|
||||
match self {
|
||||
LaunchTerminalError::Exec(src) => map.serialize_entry("source", &src)?,
|
||||
_ => serialize_upstream_err(self, &mut map)?,
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
@ -1,31 +1,31 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::state::{AppState, Session, Credentials};
|
||||
use crate::config::AppConfig;
|
||||
use crate::credentials::{Session,BaseCredentials};
|
||||
use crate::errors::*;
|
||||
use crate::clientinfo::Client;
|
||||
use crate::state::AppState;
|
||||
use crate::terminal;
|
||||
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Client {
|
||||
pub pid: u32,
|
||||
pub exe: String,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AwsRequestNotification {
|
||||
pub id: u64,
|
||||
pub clients: Vec<Client>,
|
||||
pub client: Client,
|
||||
pub base: bool,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RequestResponse {
|
||||
pub id: u64,
|
||||
pub approval: Approval,
|
||||
pub base: bool,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum Approval {
|
||||
Approved,
|
||||
Denied,
|
||||
@ -33,38 +33,69 @@ pub enum Approval {
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub fn respond(response: RequestResponse, app_state: State<'_, AppState>) -> Result<(), String> {
|
||||
app_state.send_response(response)
|
||||
.map_err(|e| format!("Error responding to request: {e}"))
|
||||
pub async fn respond(response: RequestResponse, app_state: State<'_, AppState>) -> Result<(), SendResponseError> {
|
||||
app_state.send_response(response).await
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Result<(), String> {
|
||||
app_state.decrypt(&passphrase)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Result<(), UnlockError> {
|
||||
app_state.unlock(&passphrase).await
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_session_status(app_state: State<'_, AppState>) -> String {
|
||||
let session = app_state.session.read().unwrap();
|
||||
match *session {
|
||||
pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String, ()> {
|
||||
let session = app_state.session.read().await;
|
||||
let status = match *session {
|
||||
Session::Locked(_) => "locked".into(),
|
||||
Session::Unlocked(_) => "unlocked".into(),
|
||||
Session::Unlocked{..} => "unlocked".into(),
|
||||
Session::Empty => "empty".into()
|
||||
}
|
||||
};
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn signal_activity(app_state: State<'_, AppState>) -> Result<(), ()> {
|
||||
app_state.signal_activity().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_credentials(
|
||||
credentials: Credentials,
|
||||
credentials: BaseCredentials,
|
||||
passphrase: String,
|
||||
app_state: State<'_, AppState>
|
||||
) -> Result<(), String> {
|
||||
app_state.save_creds(credentials, &passphrase)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
) -> Result<(), UnlockError> {
|
||||
app_state.new_creds(credentials, &passphrase).await
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config(app_state: State<'_, AppState>) -> Result<AppConfig, ()> {
|
||||
let config = app_state.config.read().await;
|
||||
Ok(config.clone())
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_config(config: AppConfig, app_state: State<'_, AppState>) -> Result<(), String> {
|
||||
app_state.update_config(config)
|
||||
.await
|
||||
.map_err(|e| format!("Error saving config: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[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())
|
||||
}
|
||||
|
12
src-tauri/src/lib.rs
Normal file
12
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,12 @@
|
||||
pub mod app;
|
||||
pub mod cli;
|
||||
mod config;
|
||||
mod credentials;
|
||||
pub mod errors;
|
||||
mod clientinfo;
|
||||
mod ipc;
|
||||
mod state;
|
||||
mod server;
|
||||
mod shortcuts;
|
||||
mod terminal;
|
||||
mod tray;
|
@ -3,35 +3,27 @@
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
mod errors;
|
||||
mod clientinfo;
|
||||
mod ipc;
|
||||
mod state;
|
||||
mod server;
|
||||
mod storage;
|
||||
use creddy::{
|
||||
app,
|
||||
cli,
|
||||
errors::ShowError,
|
||||
};
|
||||
|
||||
|
||||
fn main() {
|
||||
let initial_state = match state::AppState::new() {
|
||||
Ok(state) => state,
|
||||
Err(e) => {eprintln!("{}", e); return;}
|
||||
let res = match cli::parser().get_matches().subcommand() {
|
||||
None | Some(("run", _)) => {
|
||||
app::run().error_popup("Creddy encountered an error");
|
||||
Ok(())
|
||||
},
|
||||
Some(("get", m)) => cli::get(m),
|
||||
Some(("exec", m)) => cli::exec(m),
|
||||
Some(("shortcut", m)) => cli::invoke_shortcut(m),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(initial_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
ipc::unlock,
|
||||
ipc::respond,
|
||||
ipc::get_session_status,
|
||||
ipc::save_credentials,
|
||||
])
|
||||
.setup(|app| {
|
||||
let addr = std::net::SocketAddrV4::from_str("127.0.0.1:12345").unwrap();
|
||||
tauri::async_runtime::spawn(server::serve(addr, app.handle()));
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
if let Err(e) = res {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
@ -1,97 +0,0 @@
|
||||
use std::io;
|
||||
use std::net::SocketAddrV4;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::clientinfo;
|
||||
use crate::errors::RequestError;
|
||||
use crate::ipc::{Request, Approval};
|
||||
|
||||
|
||||
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) => {
|
||||
eprintln!("Error accepting connection: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// it doesn't really return Approval, we just need to placate the compiler
|
||||
async fn stall(stream: &mut TcpStream) -> Result<Approval, tokio::io::Error> {
|
||||
let delay = std::time::Duration::from_secs(1);
|
||||
loop {
|
||||
tokio::time::sleep(delay).await;
|
||||
stream.write(b"x").await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn handle(mut stream: TcpStream, app_handle: AppHandle) -> Result<(), RequestError> {
|
||||
let (chan_send, chan_recv) = oneshot::channel();
|
||||
let app_state = app_handle.state::<crate::state::AppState>();
|
||||
let request_id = app_state.register_request(chan_send);
|
||||
|
||||
let peer_addr = match stream.peer_addr()? {
|
||||
std::net::SocketAddr::V4(addr) => addr,
|
||||
_ => unreachable!(), // we only listen on IPv4
|
||||
};
|
||||
let clients = clientinfo::get_clients(peer_addr.port())?;
|
||||
|
||||
// Do we want to panic if this fails? Does that mean the frontend is dead?
|
||||
let req = Request {id: request_id, clients};
|
||||
app_handle.emit_all("credentials-request", req).unwrap();
|
||||
|
||||
let mut buf = [0; 8192]; // it's what tokio's BufReader uses
|
||||
let mut n = 0;
|
||||
loop {
|
||||
n += stream.read(&mut buf[n..]).await?;
|
||||
if &buf[(n - 4)..n] == b"\r\n\r\n" {break;}
|
||||
if n == buf.len() {return Err(RequestError::RequestTooLarge);}
|
||||
}
|
||||
|
||||
println!("{}", std::str::from_utf8(&buf).unwrap());
|
||||
|
||||
stream.write(b"HTTP/1.0 200 OK\r\n").await?;
|
||||
stream.write(b"Content-Type: application/json\r\n").await?;
|
||||
stream.write(b"X-Creddy-delaying-tactic: ").await?;
|
||||
|
||||
let approval = tokio::select!{
|
||||
e = stall(&mut stream) => e?, // this will never return Ok, just Err if it can't write to the stream
|
||||
r = chan_recv => r.unwrap(), // only panics if the sender is dropped without sending, which shouldn't happen
|
||||
};
|
||||
|
||||
if matches!(approval, Approval::Denied) {
|
||||
// because we own the stream, it gets closed when we return.
|
||||
// Unfortunately we've already signaled 200 OK, there's no way around this -
|
||||
// we have to write the status code first thing, and we have to assume that the user
|
||||
// might need more time than that gives us (especially if entering the passphrase).
|
||||
// Fortunately most AWS libs automatically retry if the request dies uncompleted, allowing
|
||||
// us to respond with a proper error status.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let creds = app_state.get_creds_serialized()?;
|
||||
|
||||
stream.write(b"\r\nContent-Length: ").await?;
|
||||
stream.write(creds.as_bytes().len().to_string().as_bytes()).await?;
|
||||
stream.write(b"\r\n\r\n").await?;
|
||||
stream.write(creds.as_bytes()).await?;
|
||||
stream.write(b"\r\n\r\n").await?;
|
||||
Ok(())
|
||||
}
|
164
src-tauri/src/server/mod.rs
Normal file
164
src-tauri/src/server/mod.rs
Normal file
@ -0,0 +1,164 @@
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::errors::*;
|
||||
use crate::clientinfo::{self, Client};
|
||||
use crate::credentials::Credentials;
|
||||
use crate::ipc::{Approval, AwsRequestNotification};
|
||||
use crate::state::AppState;
|
||||
use crate::shortcuts::{self, ShortcutAction};
|
||||
|
||||
#[cfg(windows)]
|
||||
mod server_win;
|
||||
#[cfg(windows)]
|
||||
pub use server_win::Server;
|
||||
#[cfg(windows)]
|
||||
use server_win::Stream;
|
||||
|
||||
#[cfg(unix)]
|
||||
mod server_unix;
|
||||
#[cfg(unix)]
|
||||
pub use server_unix::Server;
|
||||
#[cfg(unix)]
|
||||
use server_unix::Stream;
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum Request {
|
||||
GetAwsCredentials{
|
||||
base: bool,
|
||||
},
|
||||
InvokeShortcut(ShortcutAction),
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum Response {
|
||||
Aws(Credentials),
|
||||
Empty,
|
||||
}
|
||||
|
||||
|
||||
struct CloseWaiter<'s> {
|
||||
stream: &'s mut Stream,
|
||||
}
|
||||
|
||||
impl<'s> CloseWaiter<'s> {
|
||||
async fn wait_for_close(&mut self) -> std::io::Result<()> {
|
||||
let mut buf = [0u8; 8];
|
||||
loop {
|
||||
match self.stream.read(&mut buf).await {
|
||||
Ok(0) => break Ok(()),
|
||||
Ok(_) => (),
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> Result<(), HandlerError>
|
||||
{
|
||||
// read from stream until delimiter is reached
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(1024); // requests are small, 1KiB is more than enough
|
||||
let mut n = 0;
|
||||
loop {
|
||||
n += stream.read_buf(&mut buf).await?;
|
||||
if let Some(&b'\n') = buf.last() {
|
||||
break;
|
||||
}
|
||||
else if n >= 1024 {
|
||||
return Err(HandlerError::RequestTooLarge);
|
||||
}
|
||||
}
|
||||
|
||||
let client = clientinfo::get_process_parent_info(client_pid)?;
|
||||
let waiter = CloseWaiter { stream: &mut stream };
|
||||
|
||||
let req: Request = serde_json::from_slice(&buf)?;
|
||||
let res = match req {
|
||||
Request::GetAwsCredentials{ base } => get_aws_credentials(
|
||||
base, client, app_handle, waiter
|
||||
).await,
|
||||
Request::InvokeShortcut(action) => invoke_shortcut(action).await,
|
||||
};
|
||||
|
||||
// doesn't make sense to send the error to the client if the client has already left
|
||||
if let Err(HandlerError::Abandoned) = res {
|
||||
return Err(HandlerError::Abandoned);
|
||||
}
|
||||
|
||||
let res = serde_json::to_vec(&res).unwrap();
|
||||
stream.write_all(&res).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
async fn invoke_shortcut(action: ShortcutAction) -> Result<Response, HandlerError> {
|
||||
shortcuts::exec_shortcut(action);
|
||||
Ok(Response::Empty)
|
||||
}
|
||||
|
||||
|
||||
async fn get_aws_credentials(
|
||||
base: bool,
|
||||
client: Client,
|
||||
app_handle: AppHandle,
|
||||
mut waiter: CloseWaiter<'_>,
|
||||
) -> Result<Response, HandlerError> {
|
||||
let state = app_handle.state::<AppState>();
|
||||
let rehide_ms = {
|
||||
let config = state.config.read().await;
|
||||
config.rehide_ms
|
||||
};
|
||||
let lease = state.acquire_visibility_lease(rehide_ms).await
|
||||
.map_err(|_e| HandlerError::NoMainWindow)?; // automate this conversion eventually?
|
||||
|
||||
let (chan_send, chan_recv) = oneshot::channel();
|
||||
let request_id = state.register_request(chan_send).await;
|
||||
|
||||
// if an error occurs in any of the following, we want to abort the operation
|
||||
// but ? returns immediately, and we want to unregister the request before returning
|
||||
// so we bundle it all up in an async block and return a Result so we can handle errors
|
||||
let proceed = async {
|
||||
let notification = AwsRequestNotification {id: request_id, client, base};
|
||||
app_handle.emit("credentials-request", ¬ification)?;
|
||||
|
||||
let response = tokio::select! {
|
||||
r = chan_recv => r?,
|
||||
_ = waiter.wait_for_close() => {
|
||||
app_handle.emit("request-cancelled", request_id)?;
|
||||
return Err(HandlerError::Abandoned);
|
||||
},
|
||||
};
|
||||
|
||||
match response.approval {
|
||||
Approval::Approved => {
|
||||
if response.base {
|
||||
let creds = state.base_creds_cloned().await?;
|
||||
Ok(Response::Aws(Credentials::Base(creds)))
|
||||
}
|
||||
else {
|
||||
let creds = state.session_creds_cloned().await?;
|
||||
Ok(Response::Aws(Credentials::Session(creds)))
|
||||
}
|
||||
},
|
||||
Approval::Denied => Err(HandlerError::Denied),
|
||||
}
|
||||
};
|
||||
|
||||
let result = match proceed.await {
|
||||
Ok(r) => Ok(r),
|
||||
Err(e) => {
|
||||
state.unregister_request(request_id).await;
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
|
||||
lease.release();
|
||||
result
|
||||
}
|
58
src-tauri/src/server/server_unix.rs
Normal file
58
src-tauri/src/server/server_unix.rs
Normal file
@ -0,0 +1,58 @@
|
||||
use std::io::ErrorKind;
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tauri::{
|
||||
AppHandle,
|
||||
async_runtime as rt,
|
||||
};
|
||||
|
||||
use crate::errors::*;
|
||||
|
||||
|
||||
pub type Stream = UnixStream;
|
||||
|
||||
|
||||
pub struct Server {
|
||||
listener: UnixListener,
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn start(app_handle: AppHandle) -> std::io::Result<()> {
|
||||
match std::fs::remove_file("/tmp/creddy.sock") {
|
||||
Ok(_) => (),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => (),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind("/tmp/creddy.sock")?;
|
||||
let srv = Server { listener, app_handle };
|
||||
rt::spawn(srv.serve());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve(self) {
|
||||
loop {
|
||||
self.try_serve()
|
||||
.await
|
||||
.error_print_prefix("Error accepting request: ");
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_serve(&self) -> Result<(), HandlerError> {
|
||||
let (stream, _addr) = self.listener.accept().await?;
|
||||
let new_handle = self.app_handle.clone();
|
||||
let client_pid = get_client_pid(&stream)?;
|
||||
rt::spawn(async move {
|
||||
super::handle(stream, new_handle, client_pid)
|
||||
.await
|
||||
.error_print_prefix("Error responding to request: ");
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn get_client_pid(stream: &UnixStream) -> std::io::Result<u32> {
|
||||
let cred = stream.peer_cred()?;
|
||||
Ok(cred.pid().unwrap() as u32)
|
||||
}
|
74
src-tauri/src/server/server_win.rs
Normal file
74
src-tauri/src/server/server_win.rs
Normal file
@ -0,0 +1,74 @@
|
||||
use tokio::net::windows::named_pipe::{
|
||||
NamedPipeServer,
|
||||
ServerOptions,
|
||||
};
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use windows::Win32:: {
|
||||
Foundation::HANDLE,
|
||||
System::Pipes::GetNamedPipeClientProcessId,
|
||||
};
|
||||
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
|
||||
use tauri::async_runtime as rt;
|
||||
|
||||
use crate::errors::*;
|
||||
|
||||
|
||||
// used by parent module
|
||||
pub type Stream = NamedPipeServer;
|
||||
|
||||
|
||||
pub struct Server {
|
||||
listener: NamedPipeServer,
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn start(app_handle: AppHandle) -> std::io::Result<()> {
|
||||
let listener = ServerOptions::new()
|
||||
.first_pipe_instance(true)
|
||||
.create(r"\\.\pipe\creddy-requests")?;
|
||||
|
||||
let srv = Server {listener, app_handle};
|
||||
rt::spawn(srv.serve());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve(mut self) {
|
||||
loop {
|
||||
if let Err(e) = self.try_serve().await {
|
||||
eprintln!("Error accepting connection: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_serve(&mut self) -> Result<(), HandlerError> {
|
||||
// connect() just waits for a client to connect, it doesn't return anything
|
||||
self.listener.connect().await?;
|
||||
|
||||
// create a new pipe instance to listen for the next client, and swap it in
|
||||
let new_listener = ServerOptions::new().create(r"\\.\pipe\creddy-requests")?;
|
||||
let stream = std::mem::replace(&mut self.listener, new_listener);
|
||||
let new_handle = self.app_handle.clone();
|
||||
let client_pid = get_client_pid(&stream)?;
|
||||
rt::spawn(async move {
|
||||
super::handle(stream, new_handle, client_pid)
|
||||
.await
|
||||
.error_print_prefix("Error responding to request: ");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn get_client_pid(pipe: &NamedPipeServer) -> Result<u32, ClientInfoError> {
|
||||
let raw_handle = pipe.as_raw_handle();
|
||||
let mut pid = 0u32;
|
||||
let handle = HANDLE(raw_handle as _);
|
||||
unsafe { GetNamedPipeClientProcessId(handle, &mut pid as *mut u32)? };
|
||||
Ok(pid)
|
||||
}
|
69
src-tauri/src/shortcuts.rs
Normal file
69
src-tauri/src/shortcuts.rs
Normal file
@ -0,0 +1,69 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use tauri::async_runtime as rt;
|
||||
|
||||
use tauri_plugin_global_shortcut::{
|
||||
GlobalShortcutExt,
|
||||
Error as ShortcutError,
|
||||
};
|
||||
|
||||
use crate::app::{self, APP};
|
||||
use crate::config::HotkeysConfig;
|
||||
use crate::errors::*;
|
||||
use crate::terminal;
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum ShortcutAction {
|
||||
ShowWindow,
|
||||
LaunchTerminal,
|
||||
}
|
||||
|
||||
|
||||
pub fn exec_shortcut(action: ShortcutAction) {
|
||||
match action {
|
||||
ShortcutAction::LaunchTerminal => launch_terminal(),
|
||||
ShortcutAction::ShowWindow => {
|
||||
let app = APP.get().unwrap();
|
||||
app::show_main_window(app)
|
||||
.error_popup("Failed to show Creddy");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn launch_terminal() {
|
||||
rt::spawn(async {
|
||||
terminal::launch(false)
|
||||
.await
|
||||
.error_popup("Failed to launch terminal")
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> Result<(), ShortcutError> {
|
||||
let app = APP.get().unwrap();
|
||||
let shortcuts = app.global_shortcut();
|
||||
shortcuts.unregister_all([
|
||||
hotkeys.show_window.keys.as_str(),
|
||||
hotkeys.launch_terminal.keys.as_str(),
|
||||
])?;
|
||||
|
||||
if hotkeys.show_window.enabled {
|
||||
shortcuts.on_shortcut(
|
||||
hotkeys.show_window.keys.as_str(),
|
||||
|app, _shortcut, _event| {
|
||||
app::show_main_window(app).error_popup("Failed to show Creddy")
|
||||
}
|
||||
)?;
|
||||
}
|
||||
|
||||
if hotkeys.launch_terminal.enabled {
|
||||
shortcuts.on_shortcut(
|
||||
hotkeys.launch_terminal.keys.as_str(),
|
||||
|_app, _shortcut, _event| launch_terminal()
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
@ -1,196 +1,287 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
use tokio::sync::oneshot::Sender;
|
||||
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions, sqlite::SqliteConnectOptions};
|
||||
use sodiumoxide::crypto::{
|
||||
pwhash,
|
||||
pwhash::Salt,
|
||||
secretbox,
|
||||
secretbox::{Nonce, Key}
|
||||
use tokio::{
|
||||
sync::RwLock,
|
||||
sync::oneshot::{self, Sender},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use tauri::{
|
||||
Manager,
|
||||
async_runtime as rt,
|
||||
};
|
||||
use tauri::async_runtime as runtime;
|
||||
|
||||
use crate::ipc;
|
||||
use crate::app;
|
||||
use crate::credentials::{
|
||||
Session,
|
||||
BaseCredentials,
|
||||
SessionCredentials,
|
||||
};
|
||||
use crate::{config, config::AppConfig};
|
||||
use crate::ipc::{self, Approval, RequestResponse};
|
||||
use crate::errors::*;
|
||||
use crate::shortcuts;
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Credentials {
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
LongLived {
|
||||
access_key_id: String,
|
||||
secret_access_key: String,
|
||||
},
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
ShortLived {
|
||||
access_key_id: String,
|
||||
secret_access_key: String,
|
||||
token: String,
|
||||
expiration: String,
|
||||
},
|
||||
#[derive(Debug)]
|
||||
struct Visibility {
|
||||
leases: usize,
|
||||
original: Option<bool>,
|
||||
}
|
||||
|
||||
impl Visibility {
|
||||
fn new() -> Self {
|
||||
Visibility { leases: 0, original: None }
|
||||
}
|
||||
|
||||
fn acquire(&mut self, delay_ms: u64) -> Result<VisibilityLease, WindowError> {
|
||||
let app = crate::app::APP.get().unwrap();
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or(WindowError::NoMainWindow)?;
|
||||
|
||||
self.leases += 1;
|
||||
// `original` represents the visibility of the window before any leases were acquired
|
||||
// None means we don't know, Some(false) means it was previously hidden,
|
||||
// Some(true) means it was previously visible
|
||||
let is_visible = window.is_visible()?;
|
||||
if self.original.is_none() {
|
||||
self.original = Some(is_visible);
|
||||
}
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
if is_visible && state.desktop_is_gnome {
|
||||
// Gnome has a really annoying "focus-stealing prevention" behavior means we
|
||||
// can't just pop up when the window is already visible, so to work around it
|
||||
// we hide and then immediately unhide the window
|
||||
window.hide()?;
|
||||
}
|
||||
app::show_main_window(&app)?;
|
||||
window.set_focus()?;
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let lease = VisibilityLease { notify: tx };
|
||||
|
||||
let delay = Duration::from_millis(delay_ms);
|
||||
rt::spawn(async move {
|
||||
// We don't care if it's an error; lease being dropped should be handled identically
|
||||
let _ = rx.await;
|
||||
tokio::time::sleep(delay).await;
|
||||
// we can't use `self` here because we would have to move it into the async block
|
||||
let state = app.state::<AppState>();
|
||||
let mut visibility = state.visibility.write().await;
|
||||
visibility.leases -= 1;
|
||||
if visibility.leases == 0 {
|
||||
if let Some(false) = visibility.original {
|
||||
app::hide_main_window(app).error_print();
|
||||
}
|
||||
visibility.original = None;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(lease)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VisibilityLease {
|
||||
notify: Sender<()>,
|
||||
}
|
||||
|
||||
impl VisibilityLease {
|
||||
pub fn release(self) {
|
||||
rt::spawn(async move {
|
||||
if let Err(_) = self.notify.send(()) {
|
||||
eprintln!("Error releasing visibility lease")
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub struct LockedCredentials {
|
||||
access_key_id: String,
|
||||
secret_key_enc: Vec<u8>,
|
||||
salt: Salt,
|
||||
nonce: Nonce,
|
||||
}
|
||||
|
||||
|
||||
pub enum Session {
|
||||
Unlocked(Credentials),
|
||||
Locked(LockedCredentials),
|
||||
Empty,
|
||||
}
|
||||
|
||||
|
||||
// #[derive(Serialize, Deserialize)]
|
||||
// pub enum SessionStatus {
|
||||
// Unlocked,
|
||||
// Locked,
|
||||
// Empty,
|
||||
// }
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppState {
|
||||
pub config: RwLock<AppConfig>,
|
||||
pub session: RwLock<Session>,
|
||||
pub last_activity: RwLock<OffsetDateTime>,
|
||||
pub request_count: RwLock<u64>,
|
||||
pub open_requests: RwLock<HashMap<u64, Sender<ipc::Approval>>>,
|
||||
pool: SqlitePool,
|
||||
pub waiting_requests: RwLock<HashMap<u64, Sender<RequestResponse>>>,
|
||||
pub pending_terminal_request: RwLock<bool>,
|
||||
// these are never modified and so don't need to be wrapped in RwLocks
|
||||
pub setup_errors: Vec<String>,
|
||||
pub desktop_is_gnome: bool,
|
||||
pool: sqlx::SqlitePool,
|
||||
visibility: RwLock<Visibility>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Result<Self, SetupError> {
|
||||
let conn_opts = SqliteConnectOptions::new()
|
||||
.filename("creddy.db")
|
||||
.create_if_missing(true);
|
||||
let pool_opts = SqlitePoolOptions::new();
|
||||
|
||||
let pool: SqlitePool = runtime::block_on(pool_opts.connect_with(conn_opts))?;
|
||||
runtime::block_on(sqlx::migrate!().run(&pool))?;
|
||||
let creds = runtime::block_on(Self::load_creds(&pool))?;
|
||||
|
||||
let state = AppState {
|
||||
session: RwLock::new(creds),
|
||||
pub fn new(
|
||||
config: AppConfig,
|
||||
session: Session,
|
||||
pool: SqlitePool,
|
||||
setup_errors: Vec<String>,
|
||||
desktop_is_gnome: bool,
|
||||
) -> AppState {
|
||||
AppState {
|
||||
config: RwLock::new(config),
|
||||
session: RwLock::new(session),
|
||||
last_activity: RwLock::new(OffsetDateTime::now_utc()),
|
||||
request_count: RwLock::new(0),
|
||||
open_requests: RwLock::new(HashMap::new()),
|
||||
waiting_requests: RwLock::new(HashMap::new()),
|
||||
pending_terminal_request: RwLock::new(false),
|
||||
setup_errors,
|
||||
desktop_is_gnome,
|
||||
pool,
|
||||
};
|
||||
|
||||
Ok(state)
|
||||
visibility: RwLock::new(Visibility::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_creds(pool: &SqlitePool) -> Result<Session, SetupError> {
|
||||
let res = sqlx::query!("SELECT * FROM credentials")
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let row = match res {
|
||||
Some(r) => r,
|
||||
None => {return Ok(Session::Empty);}
|
||||
};
|
||||
|
||||
let salt_buf: [u8; 32] = row.salt
|
||||
.try_into()
|
||||
.map_err(|_e| SetupError::InvalidRecord)?;
|
||||
let nonce_buf: [u8; 24] = row.nonce
|
||||
.try_into()
|
||||
.map_err(|_e| SetupError::InvalidRecord)?;
|
||||
|
||||
let creds = LockedCredentials {
|
||||
access_key_id: row.access_key_id,
|
||||
secret_key_enc: row.secret_key_enc,
|
||||
salt: Salt(salt_buf),
|
||||
nonce: Nonce(nonce_buf),
|
||||
};
|
||||
Ok(Session::Locked(creds))
|
||||
}
|
||||
|
||||
pub async fn save_creds(&self, creds: Credentials, passphrase: &str) -> Result<(), sqlx::error::Error> {
|
||||
let (key_id, secret_key) = match creds {
|
||||
Credentials::LongLived {access_key_id, secret_access_key} => {
|
||||
(access_key_id, secret_access_key)
|
||||
},
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let salt = pwhash::gen_salt();
|
||||
let mut key_buf = [0; secretbox::KEYBYTES];
|
||||
pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), &salt).unwrap();
|
||||
let key = Key(key_buf);
|
||||
// not sure we need both salt AND nonce given that we generate a
|
||||
// fresh salt every time we encrypt, but better safe than sorry
|
||||
let nonce = secretbox::gen_nonce();
|
||||
let key_enc = secretbox::seal(secret_key.as_bytes(), &nonce, &key);
|
||||
|
||||
// insert into database
|
||||
|
||||
// eventually replace this with a temporary session
|
||||
let mut session = self.session.write().unwrap();
|
||||
*session = Session::Unlocked(Credentials::LongLived {
|
||||
access_key_id: key_id,
|
||||
secret_access_key: secret_key,
|
||||
});
|
||||
pub async fn new_creds(&self, base_creds: BaseCredentials, passphrase: &str) -> Result<(), UnlockError> {
|
||||
let locked = base_creds.encrypt(passphrase)?;
|
||||
// do this first so that if it fails we don't save bad credentials
|
||||
self.new_session(base_creds).await?;
|
||||
locked.save(&self.pool).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
|
||||
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
|
||||
let mut live_config = self.config.write().await;
|
||||
|
||||
// update autostart if necessary
|
||||
if new_config.start_on_login != live_config.start_on_login {
|
||||
config::set_auto_launch(new_config.start_on_login)?;
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
shortcuts::register_hotkeys(&new_config.hotkeys)?;
|
||||
}
|
||||
|
||||
new_config.save(&self.pool).await?;
|
||||
*live_config = new_config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn register_request(&self, sender: Sender<RequestResponse>) -> u64 {
|
||||
let count = {
|
||||
let mut c = self.request_count.write().unwrap();
|
||||
let mut c = self.request_count.write().await;
|
||||
*c += 1;
|
||||
c
|
||||
};
|
||||
|
||||
let mut open_requests = self.open_requests.write().unwrap();
|
||||
open_requests.insert(*count, chan); // `count` is the request id
|
||||
let mut waiting_requests = self.waiting_requests.write().await;
|
||||
waiting_requests.insert(*count, sender); // `count` is the request id
|
||||
*count
|
||||
}
|
||||
|
||||
pub fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
|
||||
let mut open_requests = self.open_requests.write().unwrap();
|
||||
let chan = open_requests
|
||||
.remove(&response.id)
|
||||
.ok_or(SendResponseError::NotFound)
|
||||
?;
|
||||
|
||||
chan.send(response.approval)
|
||||
.map_err(|_e| SendResponseError::Abandoned)
|
||||
pub async fn unregister_request(&self, id: u64) {
|
||||
let mut waiting_requests = self.waiting_requests.write().await;
|
||||
waiting_requests.remove(&id);
|
||||
}
|
||||
|
||||
pub async fn decrypt(&self, passphrase: &str) -> Result<(), UnlockError> {
|
||||
let session = self.session.read().unwrap();
|
||||
let locked = match *session {
|
||||
pub async fn acquire_visibility_lease(&self, delay: u64) -> Result<VisibilityLease, WindowError> {
|
||||
let mut visibility = self.visibility.write().await;
|
||||
visibility.acquire(delay)
|
||||
}
|
||||
|
||||
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
|
||||
.remove(&response.id)
|
||||
.ok_or(SendResponseError::NotFound)?
|
||||
.send(response)
|
||||
.map_err(|_| SendResponseError::Abandoned)
|
||||
}
|
||||
|
||||
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
|
||||
let base_creds = match *self.session.read().await {
|
||||
Session::Empty => {return Err(UnlockError::NoCredentials);},
|
||||
Session::Unlocked(_) => {return Err(UnlockError::NotLocked);},
|
||||
Session::Locked(ref c) => c,
|
||||
Session::Unlocked{..} => {return Err(UnlockError::NotLocked);},
|
||||
Session::Locked(ref locked) => locked.decrypt(passphrase)?,
|
||||
};
|
||||
// Read lock is dropped here, so this doesn't deadlock
|
||||
self.new_session(base_creds).await?;
|
||||
|
||||
let mut key_buf = [0; secretbox::KEYBYTES];
|
||||
// pretty sure this only fails if we're out of memory
|
||||
pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), &locked.salt).unwrap();
|
||||
let decrypted = secretbox::open(&locked.secret_key_enc, &locked.nonce, &Key(key_buf))
|
||||
.map_err(|_e| UnlockError::BadPassphrase)?;
|
||||
|
||||
let secret_str = String::from_utf8(decrypted).map_err(|_e| UnlockError::InvalidUtf8)?;
|
||||
let mut session = self.session.write().unwrap();
|
||||
let creds = Credentials::LongLived {
|
||||
access_key_id: locked.access_key_id.clone(),
|
||||
secret_access_key: secret_str,
|
||||
};
|
||||
*session = Session::Unlocked(creds);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_creds_serialized(&self) -> Result<String, GetCredentialsError> {
|
||||
let session = self.session.read().unwrap();
|
||||
pub async fn lock(&self) -> Result<(), LockError> {
|
||||
let mut session = self.session.write().await;
|
||||
match *session {
|
||||
Session::Unlocked(ref creds) => Ok(serde_json::to_string(creds).unwrap()),
|
||||
Session::Locked(_) => Err(GetCredentialsError::Locked),
|
||||
Session::Empty => Err(GetCredentialsError::Empty),
|
||||
Session::Empty => Err(LockError::NotUnlocked),
|
||||
Session::Locked(_) => Err(LockError::NotUnlocked),
|
||||
Session::Unlocked{..} => {
|
||||
*session = Session::load(&self.pool).await?;
|
||||
|
||||
let app_handle = app::APP.get().unwrap();
|
||||
app_handle.emit("locked", None::<usize>)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn signal_activity(&self) {
|
||||
let mut last_activity = self.last_activity.write().await;
|
||||
*last_activity = OffsetDateTime::now_utc();
|
||||
}
|
||||
|
||||
pub async fn should_auto_lock(&self) -> bool {
|
||||
let config = self.config.read().await;
|
||||
if !config.auto_lock || !self.is_unlocked().await {
|
||||
return false;
|
||||
}
|
||||
|
||||
let last_activity = self.last_activity.read().await;
|
||||
let elapsed = OffsetDateTime::now_utc() - *last_activity;
|
||||
elapsed >= config.lock_after
|
||||
}
|
||||
|
||||
pub async fn is_unlocked(&self) -> bool {
|
||||
let session = self.session.read().await;
|
||||
matches!(*session, Session::Unlocked{..})
|
||||
}
|
||||
|
||||
pub async fn base_creds_cloned(&self) -> Result<BaseCredentials, GetCredentialsError> {
|
||||
let app_session = self.session.read().await;
|
||||
let (base, _session) = app_session.try_get()?;
|
||||
Ok(base.clone())
|
||||
}
|
||||
|
||||
pub async fn session_creds_cloned(&self) -> Result<SessionCredentials, GetCredentialsError> {
|
||||
let app_session = self.session.read().await;
|
||||
let (_base, session) = app_session.try_get()?;
|
||||
Ok(session.clone())
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -1,42 +0,0 @@
|
||||
use sodiumoxide::crypto::{pwhash, secretbox};
|
||||
|
||||
use crate::state;
|
||||
|
||||
|
||||
pub fn save(data: &str, passphrase: &str) {
|
||||
let salt = pwhash::Salt([0; 32]); // yes yes, just for now
|
||||
let mut kbuf = [0; secretbox::KEYBYTES];
|
||||
pwhash::derive_key_interactive(&mut kbuf, passphrase.as_bytes(), &salt)
|
||||
.expect("Couldn't compute password hash. Are you out of memory?");
|
||||
let key = secretbox::Key(kbuf);
|
||||
let nonce = secretbox::Nonce([0; 24]); // we don't care about e.g. replay attacks so this might be safe?
|
||||
let encrypted = secretbox::seal(data.as_bytes(), &nonce, &key);
|
||||
|
||||
//todo: store in a database, along with salt, nonce, and hash parameters
|
||||
std::fs::write("credentials.enc", &encrypted).expect("Failed to write file.");
|
||||
|
||||
//todo: key is automatically zeroed, but we should use 'zeroize' or something to zero out passphrase and data
|
||||
}
|
||||
|
||||
|
||||
// pub fn load(passphrase: &str) -> String {
|
||||
// let salt = pwhash::Salt([0; 32]);
|
||||
// let mut kbuf = [0; secretbox::KEYBYTES];
|
||||
// pwhash::derive_key_interactive(&mut kbuf, passphrase.as_bytes(), &salt)
|
||||
// .expect("Couldn't compute password hash. Are you out of memory?");
|
||||
// let key = secretbox::Key(kbuf);
|
||||
// let nonce = secretbox::Nonce([0; 24]);
|
||||
|
||||
// let encrypted = std::fs::read("credentials.enc").expect("Failed to read file.");
|
||||
// let decrypted = secretbox::open(&encrypted, &nonce, &key).expect("Failed to decrypt.");
|
||||
// String::from_utf8(decrypted).expect("Invalid utf-8")
|
||||
// }
|
||||
|
||||
pub fn load(passphrase: &str) -> state::Credentials {
|
||||
state::Credentials::ShortLived {
|
||||
access_key_id: "ASIAZ7WSVLORKQI27QGB".to_string(),
|
||||
secret_access_key: "blah".to_string(),
|
||||
token: "gah".to_string(),
|
||||
expiration: "2022-11-29T10:45:12Z".to_string(),
|
||||
}
|
||||
}
|
78
src-tauri/src/terminal.rs
Normal file
78
src-tauri/src/terminal.rs
Normal file
@ -0,0 +1,78 @@
|
||||
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 locked or empty, wait for credentials from frontend
|
||||
if !state.is_unlocked().await {
|
||||
app.emit("launch-terminal-request", ())?;
|
||||
let lease = state.acquire_visibility_lease(0).await
|
||||
.map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually?
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.once("credentials-event", move |e| {
|
||||
let success = match e.payload() {
|
||||
"\"unlocked\"" | "\"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
|
||||
}
|
||||
lease.release();
|
||||
}
|
||||
|
||||
// more lock-management
|
||||
{
|
||||
let app_session = state.session.read().await;
|
||||
// session should really be unlocked at this point, but if the frontend misbehaves
|
||||
// (i.e. lies about unlocking) we could end up here with a locked session
|
||||
// this will result in an error popup to the user (see main hotkey handler)
|
||||
let (base_creds, session_creds) = app_session.try_get()?;
|
||||
if use_base {
|
||||
cmd.env("AWS_ACCESS_KEY_ID", &base_creds.access_key_id);
|
||||
cmd.env("AWS_SECRET_ACCESS_KEY", &base_creds.secret_access_key);
|
||||
}
|
||||
else {
|
||||
cmd.env("AWS_ACCESS_KEY_ID", &session_creds.access_key_id);
|
||||
cmd.env("AWS_SECRET_ACCESS_KEY", &session_creds.secret_access_key);
|
||||
cmd.env("AWS_SESSION_TOKEN", &session_creds.session_token);
|
||||
}
|
||||
}
|
||||
|
||||
let res = match cmd.spawn() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) if std::io::ErrorKind::NotFound == e.kind() => {
|
||||
Err(ExecError::NotFound(cmd.get_program().to_owned()))
|
||||
},
|
||||
Err(e) => Err(ExecError::ExecutionFailed(e)),
|
||||
};
|
||||
|
||||
state.unregister_terminal_request().await;
|
||||
|
||||
res?; // ? auto-conversion is more liberal than .into()
|
||||
Ok(())
|
||||
}
|
49
src-tauri/src/tray.rs
Normal file
49
src-tauri/src/tray.rs
Normal file
@ -0,0 +1,49 @@
|
||||
use tauri::{
|
||||
App,
|
||||
AppHandle,
|
||||
Manager,
|
||||
async_runtime as rt,
|
||||
};
|
||||
use tauri::menu::{
|
||||
MenuBuilder,
|
||||
MenuEvent,
|
||||
MenuItemBuilder,
|
||||
};
|
||||
|
||||
use crate::app;
|
||||
use crate::state::AppState;
|
||||
|
||||
|
||||
pub fn setup(app: &App) -> tauri::Result<()> {
|
||||
let show_hide = MenuItemBuilder::with_id("show_hide", "Show").build(app)?;
|
||||
let exit = MenuItemBuilder::with_id("exit", "Exit").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.items(&[&show_hide, &exit])
|
||||
.build()?;
|
||||
|
||||
let tray = app.tray_by_id("main").unwrap();
|
||||
tray.set_menu(Some(menu))?;
|
||||
tray.on_menu_event(handle_event);
|
||||
|
||||
// stash this so we can find it later to change the text
|
||||
app.manage(show_hide);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn handle_event(app_handle: &AppHandle, event: MenuEvent) {
|
||||
match event.id.0.as_str() {
|
||||
"exit" => app_handle.exit(0),
|
||||
"show_hide" => {
|
||||
let _ = app::toggle_main_window(app_handle);
|
||||
let new_handle = app_handle.clone();
|
||||
rt::spawn(async move {
|
||||
let state = new_handle.state::<AppState>();
|
||||
state.signal_activity().await;
|
||||
});
|
||||
},
|
||||
_ => (),
|
||||
}
|
||||
}
|
@ -3,64 +3,86 @@
|
||||
"build": {
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devPath": "http://localhost:5173",
|
||||
"distDir": "../dist"
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173"
|
||||
},
|
||||
"package": {
|
||||
"productName": "creddy",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": true
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"copyright": "",
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
"externalBin": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "com.tauri.dev",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"copyright": "",
|
||||
"targets": "all",
|
||||
"externalBin": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "",
|
||||
"wix": {
|
||||
"fragmentPaths": [
|
||||
"conf/cli.wxs"
|
||||
],
|
||||
"componentRefs": [
|
||||
"CliBinary",
|
||||
"AddToPath"
|
||||
]
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"updater": {
|
||||
"active": false
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"productName": "creddy",
|
||||
"version": "0.4.9",
|
||||
"identifier": "creddy",
|
||||
"plugins": {},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"fullscreen": false,
|
||||
"height": 600,
|
||||
"resizable": true,
|
||||
"label": "main",
|
||||
"title": "Creddy",
|
||||
"width": 800
|
||||
"width": 800,
|
||||
"visible": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"trayIcon": {
|
||||
"id": "main",
|
||||
"iconPath": "icons/icon.png",
|
||||
"iconAsTemplate": true
|
||||
},
|
||||
"security": {
|
||||
"csp": {
|
||||
"style-src": [
|
||||
"'self'",
|
||||
"'unsafe-inline'"
|
||||
],
|
||||
"default-src": [
|
||||
"'self'"
|
||||
],
|
||||
"connect-src": [
|
||||
"ipc: http://ipc.localhost"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,37 +1,64 @@
|
||||
<script>
|
||||
import { emit, listen } from '@tauri-apps/api/event';
|
||||
import queue from './lib/queue.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
|
||||
const VIEWS = import.meta.glob('./views/*.svelte', {eager: true});
|
||||
|
||||
window.emit = emit;
|
||||
window.queue = queue;
|
||||
|
||||
var appState = {
|
||||
currentRequest: null,
|
||||
pendingRequests: queue(),
|
||||
credentialStatus: 'locked',
|
||||
}
|
||||
window.appState = appState;
|
||||
import { appState, acceptRequest, cleanupRequest } from './lib/state.js';
|
||||
import { views, currentView, navigate } from './lib/routing.js';
|
||||
|
||||
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
window.invoke = invoke;
|
||||
$views = import.meta.glob('./views/*.svelte', {eager: true});
|
||||
navigate('Home');
|
||||
|
||||
|
||||
var currentView = VIEWS['./views/Home.svelte'].default;
|
||||
window.currentView = currentView;
|
||||
window.VIEWS = VIEWS;
|
||||
function navigate(svelteEvent) {
|
||||
const moduleName = `./views/${svelteEvent.detail.target}.svelte`;
|
||||
currentView = VIEWS[moduleName].default;
|
||||
}
|
||||
window.navigate = navigate;
|
||||
invoke('get_config').then(config => $appState.config = config);
|
||||
invoke('get_session_status').then(status => $appState.credentialStatus = status);
|
||||
getVersion().then(version => $appState.appVersion = version);
|
||||
|
||||
listen('credentials-request', (tauriEvent) => {
|
||||
appState.pendingRequests.put(tauriEvent.payload);
|
||||
console.log('Received request.');
|
||||
$appState.pendingRequests.put(tauriEvent.payload);
|
||||
});
|
||||
|
||||
listen('request-cancelled', (tauriEvent) => {
|
||||
const id = tauriEvent.payload;
|
||||
if (id === $appState.currentRequest?.id) {
|
||||
cleanupRequest();
|
||||
}
|
||||
else {
|
||||
const found = $appState.pendingRequests.find_remove(r => r.id === id);
|
||||
}
|
||||
});
|
||||
|
||||
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)
|
||||
}
|
||||
});
|
||||
|
||||
listen('locked', () => {
|
||||
$appState.credentialStatus = 'locked';
|
||||
});
|
||||
|
||||
invoke('get_setup_errors')
|
||||
.then(errs => {
|
||||
$appState.setupErrors = errs.map(e => ({msg: e, show: true}));
|
||||
});
|
||||
|
||||
acceptRequest();
|
||||
</script>
|
||||
|
||||
<svelte:component this={currentView} on:navigate={navigate} bind:appState={appState} />
|
||||
|
||||
<svelte:window
|
||||
on:click={() => invoke('signal_activity')}
|
||||
on:keydown={() => invoke('signal_activity')}
|
||||
/>
|
||||
|
||||
<svelte:component this="{$currentView}" />
|
||||
|
153
src/assets/vault_door.svg
Normal file
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
8
src/lib/errors.js
Normal file
@ -0,0 +1,8 @@
|
||||
export function getRootCause(error) {
|
||||
if (error.source) {
|
||||
return getRootCause(error.source);
|
||||
}
|
||||
else {
|
||||
return error;
|
||||
}
|
||||
}
|
@ -9,6 +9,10 @@ export default function() {
|
||||
|
||||
resolvers: [],
|
||||
|
||||
size() {
|
||||
return this.items.length;
|
||||
},
|
||||
|
||||
put(item) {
|
||||
this.items.push(item);
|
||||
let resolver = this.resolvers.shift();
|
||||
@ -26,5 +30,15 @@ export default function() {
|
||||
|
||||
return this.items.shift();
|
||||
},
|
||||
|
||||
find_remove(pred) {
|
||||
for (let i=0; i<this.items.length; i++) {
|
||||
if (pred(this.items[i])) {
|
||||
this.items.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}
|
||||
}
|
11
src/lib/routing.js
Normal file
11
src/lib/routing.js
Normal 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)
|
||||
}
|
35
src/lib/state.js
Normal file
35
src/lib/state.js
Normal file
@ -0,0 +1,35 @@
|
||||
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: [],
|
||||
appVersion: '',
|
||||
});
|
||||
|
||||
|
||||
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 cleanupRequest() {
|
||||
appState.update($appState => {
|
||||
$appState.currentRequest = null;
|
||||
return $appState;
|
||||
});
|
||||
currentView.set(get(previousView));
|
||||
previousView.set(null);
|
||||
acceptRequest();
|
||||
}
|
@ -1,3 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.btn-alert-error {
|
||||
@apply bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content
|
||||
}
|
||||
|
||||
/* I like alert icons to be top-aligned */
|
||||
.alert > :where(*) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
9
src/ui/Button.svelte
Normal file
9
src/ui/Button.svelte
Normal file
@ -0,0 +1,9 @@
|
||||
<script>
|
||||
import Icon from './Icon.svelte';
|
||||
export let icon = null;
|
||||
</script>
|
||||
|
||||
<button>
|
||||
{#if icon}<Icon name={icon} class="w-4 text-gray-200" />{/if}
|
||||
<slot></slot>
|
||||
</button>
|
67
src/ui/ErrorAlert.svelte
Normal file
67
src/ui/ErrorAlert.svelte
Normal file
@ -0,0 +1,67 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
let extraClasses = "";
|
||||
export {extraClasses as class};
|
||||
export let slideDuration = 150;
|
||||
let animationClass = "";
|
||||
|
||||
export function shake() {
|
||||
animationClass = 'shake';
|
||||
window.setTimeout(() => animationClass = "", 400);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
/* animation from https://svelte.dev/repl/e606c27c864045e5a9700691a7417f99?version=3.58.0 */
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateX(0px);
|
||||
}
|
||||
20% {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
90% {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
95% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0px);
|
||||
}
|
||||
}
|
||||
.shake {
|
||||
animation-name: shake;
|
||||
animation-play-state: running;
|
||||
animation-duration: 0.4s;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<div in:slide="{{duration: slideDuration}}" class="alert alert-error shadow-lg {animationClass} {extraClasses}">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>
|
||||
<slot></slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if $$slots.buttons}
|
||||
<div>
|
||||
<slot name="buttons"></slot>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
11
src/ui/Icon.svelte
Normal file
11
src/ui/Icon.svelte
Normal file
@ -0,0 +1,11 @@
|
||||
<script>
|
||||
const ICONS = import.meta.glob('./icons/*.svelte', {eager: true});
|
||||
|
||||
export let name;
|
||||
let classes = "";
|
||||
export {classes as class};
|
||||
|
||||
let svg = ICONS[`./icons/${name}.svelte`].default;
|
||||
</script>
|
||||
|
||||
<svelte:component this={svg} class={classes} />
|
15
src/ui/KeyCombo.svelte
Normal file
15
src/ui/KeyCombo.svelte
Normal file
@ -0,0 +1,15 @@
|
||||
<script>
|
||||
export let keys;
|
||||
let classes = '';
|
||||
export {classes as class};
|
||||
</script>
|
||||
|
||||
|
||||
<span class="inline-flex gap-x-[0.2em] items-center {classes}">
|
||||
{#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}
|
||||
</span>
|
43
src/ui/Link.svelte
Normal file
43
src/ui/Link.svelte
Normal file
@ -0,0 +1,43 @@
|
||||
<script>
|
||||
import { navigate } from '../lib/routing.js';
|
||||
|
||||
export let target;
|
||||
export let hotkey = null;
|
||||
export let ctrl = false
|
||||
export let alt = false;
|
||||
export let shift = false;
|
||||
|
||||
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 === event.key
|
||||
&& ctrl === event.ctrlKey
|
||||
&& alt === event.altKey
|
||||
&& shift === event.shiftKey
|
||||
) {
|
||||
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
29
src/ui/Nav.svelte
Normal 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
42
src/ui/Spinner.svelte
Normal file
@ -0,0 +1,42 @@
|
||||
<script>
|
||||
export let thickness = 8;
|
||||
let classes = '';
|
||||
export { classes as class };
|
||||
|
||||
const radius = (100 - thickness) / 2;
|
||||
// the px are fake, but we need them to satisfy css calc()
|
||||
const circumference = `${2 * Math.PI * radius}px`;
|
||||
</script>
|
||||
|
||||
|
||||
<svg
|
||||
style:--circumference={circumference}
|
||||
class={classes}
|
||||
viewBox="0 0 100 100"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<circle cx="50" cy="50" r={radius} stroke-width={thickness} />
|
||||
</svg>
|
||||
|
||||
|
||||
<style>
|
||||
circle {
|
||||
fill: transparent;
|
||||
stroke-dasharray: var(--circumference);
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center;
|
||||
animation: chase 3s infinite,
|
||||
spin 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes chase {
|
||||
0% { stroke-dashoffset: calc(-1 * var(--circumference)); }
|
||||
50% { stroke-dashoffset: calc(-2 * var(--circumference)); }
|
||||
100% { stroke-dashoffset: calc(-3 * var(--circumference)); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
50% { transform: rotate(135deg); }
|
||||
100% { transform: rotate(270deg); }
|
||||
}
|
||||
</style>
|
8
src/ui/icons/check-circle.svelte
Normal file
8
src/ui/icons/check-circle.svelte
Normal file
@ -0,0 +1,8 @@
|
||||
<script>
|
||||
let classes = "";
|
||||
export {classes as class};
|
||||
</script>
|
||||
|
||||
<svg class={classes} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
9
src/ui/icons/cog-8-tooth.svelte
Normal file
9
src/ui/icons/cog-8-tooth.svelte
Normal file
@ -0,0 +1,9 @@
|
||||
<script>
|
||||
let classes = "";
|
||||
export {classes as class};
|
||||
</script>
|
||||
|
||||
<svg class="w-6 h-6 {classes}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
8
src/ui/icons/home.svelte
Normal file
8
src/ui/icons/home.svelte
Normal file
@ -0,0 +1,8 @@
|
||||
<script>
|
||||
let classes = "";
|
||||
export {classes as class};
|
||||
</script>
|
||||
|
||||
<svg class="w-6 h-6 {classes}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
8
src/ui/icons/x-circle.svelte
Normal file
8
src/ui/icons/x-circle.svelte
Normal file
@ -0,0 +1,8 @@
|
||||
<script>
|
||||
let classes = "";
|
||||
export {classes as class};
|
||||
</script>
|
||||
|
||||
<svg class={classes} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
27
src/ui/settings/FileSetting.svelte
Normal file
27
src/ui/settings/FileSetting.svelte
Normal file
@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import Setting from './Setting.svelte';
|
||||
|
||||
export let title;
|
||||
export let value;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
|
||||
<Setting {title}>
|
||||
<div slot="input">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered grow text-right"
|
||||
bind:value
|
||||
on:change={() => dispatch('update', {value})}
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
on:click={async () => value = await open()}
|
||||
>Browse</button>
|
||||
</div>
|
||||
<slot name="description" slot="description"></slot>
|
||||
</Setting>
|
72
src/ui/settings/Keybind.svelte
Normal file
72
src/ui/settings/Keybind.svelte
Normal file
@ -0,0 +1,72 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import KeyCombo from '../KeyCombo.svelte';
|
||||
|
||||
export let description;
|
||||
export let value;
|
||||
|
||||
const id = Math.random().toString().slice(2);
|
||||
const dispatch = createEventDispatcher();
|
||||
const MODIFIERS = new Set(['Alt', 'AltGraph', 'Control', 'Fn', 'FnLock', 'Meta', 'Shift', 'Super', ]);
|
||||
|
||||
|
||||
let listening = false;
|
||||
let keysPressed = [];
|
||||
|
||||
function addModifiers(event) {
|
||||
// add modifier key if it isn't already present
|
||||
if (MODIFIERS.has(event.key) && keysPressed.indexOf(event.key) === -1) {
|
||||
keysPressed.push(event.key);
|
||||
}
|
||||
}
|
||||
|
||||
function addMainKey(event) {
|
||||
if (!MODIFIERS.has(event.key)) {
|
||||
keysPressed.push(event.key);
|
||||
|
||||
value.keys = keysPressed.join('+');
|
||||
dispatch('update', {value});
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
|
||||
function listen() {
|
||||
// don't re-listen if we already are
|
||||
if (listening) return;
|
||||
|
||||
listening = true;
|
||||
window.addEventListener('keydown', addModifiers);
|
||||
window.addEventListener('keyup', addMainKey);
|
||||
// setTimeout avoids reacting to the click event that we are currently processing
|
||||
setTimeout(() => window.addEventListener('click', unlisten), 0);
|
||||
}
|
||||
|
||||
function unlisten() {
|
||||
listening = false;
|
||||
keysPressed = [];
|
||||
window.removeEventListener('keydown', addModifiers);
|
||||
window.removeEventListener('keyup', addMainKey);
|
||||
window.removeEventListener('click', unlisten);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<input
|
||||
{id}
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={value.enabled}
|
||||
on:change={() => dispatch('update', {value})}
|
||||
>
|
||||
<label for={id} class="cursor-pointer ml-4 text-lg">{description}</label>
|
||||
|
||||
<button class="h-12 p-2 rounded border border-neutral cursor-pointer text-center" on:click={listen}>
|
||||
{#if listening}
|
||||
Click to cancel
|
||||
{:else}
|
||||
<KeyCombo keys={value.keys.split('+')} />
|
||||
{/if}
|
||||
</button>
|
87
src/ui/settings/NumericSetting.svelte
Normal file
87
src/ui/settings/NumericSetting.svelte
Normal file
@ -0,0 +1,87 @@
|
||||
<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;
|
||||
export let debounceInterval = 0;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
$: localValue = value.toString();
|
||||
let lastInputTime = null;
|
||||
function debounce(event) {
|
||||
localValue = localValue.replace(/[^-0-9.]/g, '');
|
||||
|
||||
if (debounceInterval === 0) {
|
||||
updateValue(localValue);
|
||||
return;
|
||||
}
|
||||
|
||||
lastInputTime = Date.now();
|
||||
const eventTime = lastInputTime;
|
||||
const pendingValue = localValue;
|
||||
window.setTimeout(
|
||||
() => {
|
||||
// if no other inputs have occured since then
|
||||
if (eventTime === lastInputTime) {
|
||||
updateValue(pendingValue);
|
||||
}
|
||||
},
|
||||
debounceInterval,
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
22
src/ui/settings/Setting.svelte
Normal file
22
src/ui/settings/Setting.svelte
Normal 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>
|
14
src/ui/settings/SettingsGroup.svelte
Normal file
14
src/ui/settings/SettingsGroup.svelte
Normal file
@ -0,0 +1,14 @@
|
||||
<script>
|
||||
export let name;
|
||||
</script>
|
||||
|
||||
|
||||
<div>
|
||||
<div class="divider mt-0 mb-8">
|
||||
<h2 class="text-xl font-bold">{name}</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-12">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
22
src/ui/settings/TextSetting.svelte
Normal file
22
src/ui/settings/TextSetting.svelte
Normal file
@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Setting from './Setting.svelte';
|
||||
|
||||
export let title;
|
||||
export let value;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
|
||||
<Setting {title}>
|
||||
<div slot="input">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered grow text-right"
|
||||
bind:value
|
||||
on:change={() => dispatch('update', {value})}
|
||||
>
|
||||
</div>
|
||||
<slot name="description" slot="description"></slot>
|
||||
</Setting>
|
92
src/ui/settings/TimeSetting.svelte
Normal file
92
src/ui/settings/TimeSetting.svelte
Normal file
@ -0,0 +1,92 @@
|
||||
<script>
|
||||
import Setting from './Setting.svelte';
|
||||
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
||||
export let title;
|
||||
// seconds are required
|
||||
export let seconds;
|
||||
|
||||
export let min = 0;
|
||||
export let max = null;
|
||||
|
||||
// best unit is the unit that results in the smallest non-fractional number
|
||||
let unit = null;
|
||||
|
||||
const UNITS = {
|
||||
Seconds: 1,
|
||||
Minutes: 60,
|
||||
Hours: 3600,
|
||||
Days: 86400,
|
||||
};
|
||||
|
||||
if (unit === null) {
|
||||
let min = Infinity;
|
||||
let bestUnit = null;
|
||||
for (const [u, multiplier] of Object.entries(UNITS)) {
|
||||
const v = seconds / multiplier;
|
||||
if (v < min && v >= 1) {
|
||||
min = v;
|
||||
bestUnit = u;
|
||||
}
|
||||
}
|
||||
unit = bestUnit;
|
||||
}
|
||||
|
||||
|
||||
// local value is only one-way synced to value so that we can better handle changes
|
||||
$: localValue = (seconds / UNITS[unit]).toString();
|
||||
let error = null;
|
||||
|
||||
function updateValue() {
|
||||
localValue = localValue.replace(/[^0-9.]/g, '');
|
||||
// Don't update the value, but also don't error, if it's empty,
|
||||
// or if it could be the start of a float
|
||||
if (localValue === '' || localValue === '.') {
|
||||
error = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const num = parseFloat(localValue);
|
||||
if (num < 0) {
|
||||
error = `${num} is not a valid duration`
|
||||
}
|
||||
else if (min !== null && num < min) {
|
||||
error = `Too low (minimum ${min * UNITS[unit]}`;
|
||||
}
|
||||
else if (max !== null & num > max) {
|
||||
error = `Too high (maximum ${max * UNITS[unit]}`;
|
||||
}
|
||||
else {
|
||||
error = null;
|
||||
seconds = Math.round(num * UNITS[unit]);
|
||||
dispatch('update', {seconds});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<Setting {title}>
|
||||
<div slot="input">
|
||||
<select class="select select-bordered select-sm mr-2" bind:value={unit}>
|
||||
{#each Object.keys(UNITS) as u}
|
||||
<option selected={u === unit || null}>{u}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<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={updateValue}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="description" slot="description"></slot>
|
||||
</Setting>
|
22
src/ui/settings/ToggleSetting.svelte
Normal file
22
src/ui/settings/ToggleSetting.svelte
Normal file
@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import Setting from './Setting.svelte';
|
||||
|
||||
export let title;
|
||||
export let value;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
|
||||
<Setting {title}>
|
||||
<input
|
||||
slot="input"
|
||||
type="checkbox"
|
||||
class="toggle toggle-success"
|
||||
bind:checked={value}
|
||||
on:change={e => dispatch('update', {value: e.target.checked})}
|
||||
/>
|
||||
<slot name="description" slot="description"></slot>
|
||||
</Setting>
|
6
src/ui/settings/index.js
Normal file
6
src/ui/settings/index.js
Normal file
@ -0,0 +1,6 @@
|
||||
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';
|
||||
export { default as TimeSetting } from './TimeSetting.svelte';
|
@ -1,39 +1,145 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export let appState;
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import { appState, cleanupRequest } from '../lib/state.js';
|
||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||
import Link from '../ui/Link.svelte';
|
||||
import KeyCombo from '../ui/KeyCombo.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
async function approve() {
|
||||
// Send response to backend, display error if applicable
|
||||
let error, alert;
|
||||
async function respond() {
|
||||
const response = {
|
||||
id: $appState.currentRequest.id,
|
||||
...$appState.currentRequest.response,
|
||||
};
|
||||
try {
|
||||
await invoke('respond', {response});
|
||||
navigate('ShowResponse');
|
||||
}
|
||||
catch (e) {
|
||||
if (error) {
|
||||
alert.shake();
|
||||
}
|
||||
error = e;
|
||||
}
|
||||
}
|
||||
|
||||
// Approval has one of several outcomes depending on current credential state
|
||||
async function approve(base) {
|
||||
$appState.currentRequest.response = {approval: 'Approved', base};
|
||||
let status = await invoke('get_session_status');
|
||||
if (status === 'unlocked') {
|
||||
dispatch('navigate', {target: 'ShowApproved'});
|
||||
await respond();
|
||||
}
|
||||
else if (status === 'locked') {
|
||||
dispatch('navigate', {target: 'Unlock'})
|
||||
navigate('Unlock');
|
||||
}
|
||||
else {
|
||||
dispatch('navigate', {target: 'EnterCredentials'});
|
||||
navigate('EnterCredentials');
|
||||
}
|
||||
}
|
||||
|
||||
function deny() {
|
||||
dispatch('navigate', {target: 'ShowDenied'});
|
||||
// Denial has only one
|
||||
async function deny() {
|
||||
$appState.currentRequest.response = {approval: 'Denied', base: false};
|
||||
await respond();
|
||||
}
|
||||
|
||||
// Extract executable name from full path
|
||||
const client = $appState.currentRequest.client;
|
||||
const m = client.exe?.match(/\/([^/]+?$)|\\([^\\]+?$)/);
|
||||
const appName = m[1] || m[2];
|
||||
|
||||
// Executable paths can be long, so ensure they only break on \ or /
|
||||
function breakPath(path) {
|
||||
return path.replace(/(\\|\/)/g, '$1<wbr>');
|
||||
}
|
||||
|
||||
// if the request has already been approved/denied, send response immediately
|
||||
onMount(async () => {
|
||||
if ($appState.currentRequest.response) {
|
||||
await respond();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<h2 class="text-3xl text-gray-200">An application would like to access your AWS credentials.</h2>
|
||||
|
||||
<button on:click={approve}>
|
||||
<svg class="w-32 stroke-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Don't render at all if we're just going to immediately proceed to the next screen -->
|
||||
{#if error || !$appState.currentRequest?.response}
|
||||
<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.msg}
|
||||
<svelte:fragment slot="buttons">
|
||||
<button class="btn btn-sm btn-alert-error" on:click={cleanupRequest}>Cancel</button>
|
||||
<button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button>
|
||||
</svelte:fragment>
|
||||
</ErrorAlert>
|
||||
{/if}
|
||||
|
||||
<button on:click={deny}>
|
||||
<svg class="w-32 stroke-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if $appState.currentRequest?.base}
|
||||
<div class="alert alert-warning shadow-lg">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>
|
||||
WARNING: This application is requesting your base 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">
|
||||
<div class="text-right">Path:</div>
|
||||
<code class="">{@html client.exe ? breakPath(client.exe) : 'Unknown'}</code>
|
||||
<div class="text-right">PID:</div>
|
||||
<code>{client.pid}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full grid grid-cols-[1fr_auto] items-center gap-y-6">
|
||||
<!-- Don't display the option to approve with session credentials if base was specifically requested -->
|
||||
{#if !$appState.currentRequest?.base}
|
||||
<h3 class="font-semibold">
|
||||
Approve with session credentials
|
||||
</h3>
|
||||
<Link target={() => approve(false)} hotkey="Enter" shift={true}>
|
||||
<button class="w-full btn btn-success">
|
||||
<KeyCombo keys={['Shift', 'Enter']} />
|
||||
</button>
|
||||
</Link>
|
||||
{/if}
|
||||
|
||||
<h3 class="font-semibold">
|
||||
<span class="mr-2">
|
||||
{#if $appState.currentRequest?.base}
|
||||
Approve
|
||||
{:else}
|
||||
Approve with base credentials
|
||||
{/if}
|
||||
</span>
|
||||
</h3>
|
||||
<Link target={() => approve(true)} hotkey="Enter" shift={true} ctrl={true}>
|
||||
<button class="w-full btn btn-warning">
|
||||
<KeyCombo keys={['Ctrl', 'Shift', 'Enter']} />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<h3 class="font-semibold">
|
||||
<span class="mr-2">Deny</span>
|
||||
</h3>
|
||||
<Link target={deny} hotkey="Escape">
|
||||
<button class="w-full btn btn-error">
|
||||
<KeyCombo keys={['Esc']} />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -1,41 +1,94 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { getRootCause } from '../lib/errors.js';
|
||||
|
||||
export let appState;
|
||||
import { appState } from '../lib/state.js';
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import Link from '../ui/Link.svelte';
|
||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||
import Spinner from '../ui/Spinner.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let AccessKeyId, SecretAccessKey, passphrase
|
||||
|
||||
let errorMsg = null;
|
||||
let alert;
|
||||
let AccessKeyId, SecretAccessKey, passphrase, confirmPassphrase
|
||||
|
||||
function confirm() {
|
||||
if (passphrase !== confirmPassphrase) {
|
||||
errorMsg = 'Passphrases do not match.'
|
||||
}
|
||||
}
|
||||
|
||||
let saving = false;
|
||||
async function save() {
|
||||
if (passphrase !== confirmPassphrase) {
|
||||
alert.shake();
|
||||
return;
|
||||
}
|
||||
|
||||
let credentials = {AccessKeyId, SecretAccessKey};
|
||||
try {
|
||||
console.log('Saving credentials.');
|
||||
let credentials = {AccessKeyId, SecretAccessKey};
|
||||
console.log(credentials);
|
||||
saving = true;
|
||||
await invoke('save_credentials', {credentials, passphrase});
|
||||
if (appState.currentRequest) {
|
||||
dispatch('navigate', {target: 'ShowApproved'})
|
||||
emit('credentials-event', 'entered');
|
||||
$appState.credentialStatus = 'unlocked';
|
||||
if ($appState.currentRequest) {
|
||||
navigate('Approve');
|
||||
}
|
||||
else {
|
||||
dispatch('navigate', {target: 'Home'})
|
||||
navigate('Home');
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.log("Error saving credentials:", e);
|
||||
const root = getRootCause(e);
|
||||
if (e.code === 'GetSession' && root.code) {
|
||||
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
|
||||
}
|
||||
else {
|
||||
// some of the built-in Tauri errors are plain strings,
|
||||
// so fall back to e if e.msg doesn't exist
|
||||
errorMsg = e.msg || e;
|
||||
}
|
||||
|
||||
// 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}">
|
||||
<div class="text-gray-200">AWS Access Key ID</div>
|
||||
<input class="text-gray-200 bg-zinc-800" type="text" bind:value="{AccessKeyId}" />
|
||||
|
||||
<div class="text-gray-200">AWS Secret Access Key</div>
|
||||
<input class="text-gray-200 bg-zinc-800" type="text" bind:value="{SecretAccessKey}" />
|
||||
<form action="#" on:submit|preventDefault="{save}" class="form-control space-y-4 max-w-sm m-auto p-4 h-screen justify-center">
|
||||
<h2 class="text-2xl font-bold text-center">Enter your credentials</h2>
|
||||
|
||||
<div class="text-gray-200">Passphrase</div>
|
||||
<input class="text-gray-200 bg-zinc-800" type="text" bind:value="{passphrase}" />
|
||||
{#if errorMsg}
|
||||
<ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert>
|
||||
{/if}
|
||||
|
||||
<input class="text-gray-200" type="submit" />
|
||||
<input type="text" placeholder="AWS Access Key ID" bind:value="{AccessKeyId}" class="input input-bordered" />
|
||||
<input type="password" placeholder="AWS Secret Access Key" bind:value="{SecretAccessKey}" class="input input-bordered" />
|
||||
<input type="password" placeholder="Passphrase" bind:value="{passphrase}" class="input input-bordered" />
|
||||
<input type="password" placeholder="Re-enter passphrase" bind:value={confirmPassphrase} class="input input-bordered" on:change={confirm} />
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{#if saving }
|
||||
<Spinner class="w-5 h-5" thickness="12"/>
|
||||
{:else}
|
||||
Submit
|
||||
{/if}
|
||||
</button>
|
||||
<Link target={cancel} hotkey="Escape">
|
||||
<button class="btn btn-sm btn-outline w-full">Cancel</button>
|
||||
</Link>
|
||||
</form>
|
||||
|
@ -1,18 +1,65 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export let appState;
|
||||
import { appState } from '../lib/state.js';
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import Nav from '../ui/Nav.svelte';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import Link from '../ui/Link.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
import vaultDoorSvg from '../assets/vault_door.svg?raw';
|
||||
|
||||
onMount(async () => {
|
||||
// will block until a request comes in
|
||||
let req = await appState.pendingRequests.get();
|
||||
appState.currentRequest = req;
|
||||
console.log('Got credentials request from queue:');
|
||||
console.log(req);
|
||||
dispatch('navigate', {target: 'Approve'});
|
||||
});
|
||||
let launchBase = false;
|
||||
function launchTerminal() {
|
||||
invoke('launch_terminal', {base: launchBase});
|
||||
launchBase = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1 class="text-4xl text-gray-300">Creddy</h1>
|
||||
|
||||
<Nav position="fixed">
|
||||
<h2 slot="title" class="text-3xl font-bold">Creddy</h2>
|
||||
</Nav>
|
||||
|
||||
<div class="flex flex-col h-screen items-center justify-center p-4 space-y-4">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
{@html vaultDoorSvg}
|
||||
{#if $appState.credentialStatus === '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 $appState.credentialStatus === '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">
|
||||
<span class="label-text">Launch with long-lived credentials</span>
|
||||
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}>
|
||||
</label>
|
||||
|
||||
{:else if $appState.credentialStatus === '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}
|
||||
</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}
|
136
src/views/Settings.svelte
Normal file
136
src/views/Settings.svelte
Normal file
@ -0,0 +1,136 @@
|
||||
<script>
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { type } from '@tauri-apps/plugin-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, TimeSetting } from '../ui/settings';
|
||||
|
||||
import { fly } from 'svelte/transition';
|
||||
import { backInOut } from 'svelte/easing';
|
||||
|
||||
|
||||
// make an independent copy so it can differ from the main config object
|
||||
let config = JSON.parse(JSON.stringify($appState.config));
|
||||
$: configModified = JSON.stringify(config) !== JSON.stringify($appState.config);
|
||||
|
||||
let error = null;
|
||||
async function save() {
|
||||
try {
|
||||
await invoke('save_config', {config});
|
||||
$appState.config = await invoke('get_config');
|
||||
}
|
||||
catch (e) {
|
||||
error = e;
|
||||
}
|
||||
}
|
||||
|
||||
let osType = null;
|
||||
type().then(t => osType = t);
|
||||
</script>
|
||||
|
||||
|
||||
<Nav>
|
||||
<h1 slot="title" class="text-2xl font-bold">Settings</h1>
|
||||
</Nav>
|
||||
|
||||
<div class="max-w-lg mx-auto my-1.5 p-4 space-y-16">
|
||||
<SettingsGroup name="General">
|
||||
<ToggleSetting title="Start on login" bind:value={config.start_on_login}>
|
||||
<svelte:fragment slot="description">
|
||||
Start Creddy when you log in to your computer.
|
||||
</svelte:fragment>
|
||||
</ToggleSetting>
|
||||
|
||||
<ToggleSetting title="Start minimized" bind:value={config.start_minimized}>
|
||||
<svelte:fragment slot="description">
|
||||
Minimize to the system tray at startup.
|
||||
</svelte:fragment>
|
||||
</ToggleSetting>
|
||||
|
||||
<NumericSetting title="Re-hide delay" bind:value={config.rehide_ms} min={0} unit="Milliseconds">
|
||||
<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>
|
||||
|
||||
<ToggleSetting title="Lock when idle" bind:value={config.auto_lock}>
|
||||
<svelte:fragment slot="description">
|
||||
Automatically lock Creddy after a period of inactivity.
|
||||
</svelte:fragment>
|
||||
</ToggleSetting>
|
||||
|
||||
{#if config.auto_lock}
|
||||
<TimeSetting title="Idle timeout" bind:seconds={config.lock_after.secs}>
|
||||
<svelte:fragment slot="description">
|
||||
How long to wait before automatically locking.
|
||||
</svelte:fragment>
|
||||
</TimeSetting>
|
||||
{/if}
|
||||
|
||||
<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={config.terminal.exec}
|
||||
|
||||
>
|
||||
<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" bind:value={config.hotkeys.show_window} />
|
||||
<Keybind description="Launch terminal" bind:value={config.hotkeys.launch_terminal} />
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<p class="text-sm text-right">
|
||||
Creddy {$appState.appVersion}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#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>
|
||||
{:else if configModified}
|
||||
<div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast">
|
||||
<div class="alert shadow-lg no-animation">
|
||||
<span>You have unsaved changes.</span>
|
||||
|
||||
<div>
|
||||
<!-- <button class="btn btn-sm btn-ghost">Cancel</button> -->
|
||||
<button class="btn btn-sm btn-primary" on:click={save}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
@ -1,21 +0,0 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
export let appState;
|
||||
|
||||
onMount(async () => {
|
||||
let response = {
|
||||
id: appState.currentRequest.id,
|
||||
approval: 'Approved',
|
||||
}
|
||||
await invoke('respond', {response});
|
||||
appState.currentRequest = null;
|
||||
});
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
window.setTimeout(() => dispatch('navigate', {target: 'Home'}), 3000);
|
||||
</script>
|
||||
|
||||
<h1 class="text-4xl text-gray-300">Approved!</h1>
|
@ -1,21 +0,0 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
export let appState;
|
||||
|
||||
onMount(async () => {
|
||||
let response = {
|
||||
id: appState.currentRequest.id,
|
||||
approval: 'Denied',
|
||||
}
|
||||
await invoke('respond', {response});
|
||||
appState.currentRequest = null;
|
||||
});
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
window.setTimeout(() => dispatch('navigate', {target: 'Home'}), 3000);
|
||||
</script>
|
||||
|
||||
<h1 class="text-4xl text-gray-300">Denied!</h1>
|
38
src/views/ShowResponse.svelte
Normal file
38
src/views/ShowResponse.svelte
Normal file
@ -0,0 +1,38 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { draw, fade } from 'svelte/transition';
|
||||
|
||||
import { appState, cleanupRequest } 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(
|
||||
cleanupRequest,
|
||||
// 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.response.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.response.approval}!
|
||||
</div>
|
||||
</div>
|
@ -1,25 +1,95 @@
|
||||
<script>
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let appState;
|
||||
import { appState } from '../lib/state.js';
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import { getRootCause } from '../lib/errors.js';
|
||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||
import Link from '../ui/Link.svelte';
|
||||
import Spinner from '../ui/Spinner.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let errorMsg = null;
|
||||
let alert;
|
||||
let passphrase = '';
|
||||
let loadTime = 0;
|
||||
let saving = false;
|
||||
async function unlock() {
|
||||
console.log('invoking unlock command.')
|
||||
// The hotkey for navigating here from homepage is Enter, which also
|
||||
// happens to trigger the form submit event
|
||||
if (Date.now() - loadTime < 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('unlock', {passphrase});
|
||||
saving = true;
|
||||
let r = await invoke('unlock', {passphrase});
|
||||
$appState.credentialStatus = 'unlocked';
|
||||
emit('credentials-event', 'unlocked');
|
||||
if ($appState.currentRequest) {
|
||||
navigate('Approve');
|
||||
}
|
||||
else {
|
||||
navigate('Home');
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.log('Unlock 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', 'unlock-canceled');
|
||||
if ($appState.currentRequest !== null) {
|
||||
// dirty hack to prevent spurious error when returning to approve screen
|
||||
delete $appState.currentRequest.response;
|
||||
navigate('Approve');
|
||||
}
|
||||
else {
|
||||
navigate('Home');
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadTime = Date.now();
|
||||
})
|
||||
</script>
|
||||
|
||||
<form action="#" on:submit|preventDefault="{unlock}">
|
||||
<div class="text-gray-200">Enter your passphrase:</div>
|
||||
<input class="text-gray-200 bg-zinc-800" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" />
|
||||
|
||||
<form action="#" on:submit|preventDefault="{unlock}" class="form-control space-y-4 max-w-sm m-auto p-4 h-screen justify-center">
|
||||
<h2 class="font-bold text-2xl text-center">Enter your passphrase</h2>
|
||||
|
||||
{#if errorMsg}
|
||||
<ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input autofocus name="password" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" class="input input-bordered" />
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{#if saving}
|
||||
<Spinner class="w-5 h-5" thickness="12"/>
|
||||
{:else}
|
||||
Submit
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<Link target={cancel} hotkey="Escape">
|
||||
<button class="btn btn-sm btn-outline w-full">Cancel</button>
|
||||
</Link>
|
||||
</form>
|
||||
|
@ -7,5 +7,7 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [
|
||||
require('daisyui'),
|
||||
],
|
||||
}
|
||||
|
Reference in New Issue
Block a user