Compare commits
148 Commits
Author | SHA1 | Date | |
---|---|---|---|
295698e62f | |||
3b61aa924a | |||
02ba19d709 | |||
55801384eb | |||
27c2f467c4 | |||
cab5ec40cc | |||
5cf848f7fe | |||
a32e36be7e | |||
10231df860 | |||
ae93a57aab | |||
9fd355b68e | |||
00089d7efb | |||
0124f77f7b | |||
6711ce2c43 | |||
a3a11897c2 | |||
5e6542d08e | |||
f311fde74e | |||
acc5c71bfa | |||
504c0b4156 | |||
bf0a2ca72d | |||
bb980c5eef | |||
ce7d75f15a | |||
37b44ddb2e | |||
8c668e51a6 | |||
9928996fab | |||
d0a2532c27 | |||
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 | ||
10fd1d6028 | |||
67705aa2d1 | |||
|
9055fa41aa | ||
|
48269855e5 | ||
1e4e1c9a5f | |||
196510e9a2 | |||
e423df8e51 | |||
|
2cfde4d841 | ||
|
7d462645b4 | ||
|
8c271281f7 | ||
|
234d9e0471 | ||
|
397928b8f1 | ||
c19b573b26 | |||
cee43342b9 |
9
.gitignore
vendored
9
.gitignore
vendored
@ -1,8 +1,7 @@
|
|||||||
dist
|
dist
|
||||||
**/node_modules
|
**/node_modules
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
|
**/creddy.db
|
||||||
# just in case
|
# .env is system-specific
|
||||||
credentials*
|
.env
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
25
README.md
25
README.md
@ -1 +1,24 @@
|
|||||||
## Creddy: Low-friction AWS credential manager
|
## Creddy: Low-friction AWS credential helper
|
||||||
|
|
||||||
|
_Security at the expense of usability comes at the expense of security._ - Avi Douglen
|
||||||
|
|
||||||
|
**Creddy** is an AWS credential helper that focuses on improving security without interrupting your workflow (much). It works by mimicking the AWS Instance Metadata Service and requesting your approval before granting any application access to your AWS credentials. Additionally, the credentials it hands out are short-lived session credentials rather than long-lived credentials, meaning that even if they are compromised, the damage that the attacker can do is limited.
|
||||||
|
|
||||||
|
### What was wrong with all the existing AWS credential managers?
|
||||||
|
|
||||||
|
Most other AWS credential managers that I have seen differ in two ways.
|
||||||
|
|
||||||
|
**First**, they require the user to be _proactive_ instead of _reactive_, i.e. you must remember "this command will require AWS credentials" and invoke it in some special way. By contrast, Creddy waits patiently in the background until an application requests credentials, then asks for your approval before proceeding. In most cases, this requires only a couple of keystrokes, after which your original operation continues as invoked. This completely prevents the frustrating workflow of:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ aws do-something-interesting
|
||||||
|
...
|
||||||
|
...
|
||||||
|
Unable to locate credentials. You can configure credentials by running "aws configure".
|
||||||
|
# a deep sigh of the most profound resignation
|
||||||
|
$ with-aws-credentials aws do-something-interesting
|
||||||
|
```
|
||||||
|
|
||||||
|
**Second**, other credential managers are mostly backed by the system credential store. While this may sound like a good idea, it has a critical weakness: By default, on most systems, a user's credentials are accessible to _any process running as that user_. In other words, if your quick nodejs script happens to depend on a compromised module, congratulations: you have just given that module access to your AWS account.
|
||||||
|
|
||||||
|
By contrast, Creddy encrypts your main long-lived AWS credentials with a passphrase (using libsodium's `SecretBox`) and, importantly, _does not store that passphrase_. Although this means that you, the user, must re-enter the passphrase every time Creddy needs to generate a new session, this is normally only necessary about once per day. In my own opinion, this is a worthwhile tradeoff.
|
||||||
|
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.
|
50
doc/security.md
Normal file
50
doc/security.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
## Security considerations
|
||||||
|
|
||||||
|
The following is a list of security features that I hope to add eventually, in approximately the order in which I expect to add them.
|
||||||
|
|
||||||
|
* Request logging, obviously.
|
||||||
|
* Disallow all Tauri APIs except for `invoke` and `emit`. The sole job of the frontend should be to collect user interaction. Everything else should be mediated through the backend.
|
||||||
|
* Maximally-restrictive CSP - not sure if Tauri does this by default. Also not sure whether it will interfere with IPC to set a zero-access CSP.
|
||||||
|
* Allow user to specify a role to assume, so that role can be given narrower permissions. Allow falling back to the root credentials in the event that broader permissions are required. (Unsure about this one, is there a good way to make it low-friction?)
|
||||||
|
* To defend against the possibility that an attacker could replace, say, the `aws` executable with a malicious one that snarfs your credentials and then passes the command on to the real one, maybe track the path (and maybe even the hash) of the executable, and raise a warning if this is the first time we've seen that one? Using the hash would be safer, but would also introduce a lot of false positives, since every time the application gets updated it would trigger. On the other hand, users should presumably know when they've updated things, so maybe it would be ok. On the _other_ other hand, if somebody doesn't use `aws` very often then it might be weeks or months in between updating it and actually using the updated executable, in which case they probably won't remember that this is the first time they've used it since updating.
|
||||||
|
Another possible approach is to _watch_ the files in question, and alert the user whenever any of them changes. Presumably the user will know whether this change is expected or not.
|
||||||
|
* Downgrade privileges after launching. In particular, if possible, disallow any kind of outgoing network access (obviously we have to bind the listening socket, but maybe we can filter that down to _just_ the ability to bind that particular address/port) and filesystem access outside of state db. I think this is doable on Linux, although it may involve high levels of `seccomp` grossness. No idea whether it's possible on Windows. Probably possible on MacOS although it may require lengths to which I am currently unwilling to go (e.g. pay for a certificate from Apple or something.)
|
||||||
|
* "Panic button" - if a potential attack is detected (e.g. the user denies a request but Creddy discovers the request has already succeeded somehow), offer a one-click option to lock out the current IAM user. Sadly, you can't revoke session tokens, so this is the only way to limit a potential compromise. 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.)
|
||||||
|
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
Who exactly are we defending against and why?
|
||||||
|
|
||||||
|
The basic idea behind Creddy is that it provides "gap coverage" between two wildly different security 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 (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.
|
||||||
|
|
||||||
|
## Particular attacks
|
||||||
|
|
||||||
|
There are lots of ways that I can imagine someone might try to circumvent Creddy's protection. Most of them require that the attacker be targeting Creddy in particular, rather than just "AWS credentials generally". In addition, most of them are "noisy" - that is, there's a good chance that the attack will alert the user to the fact that they are being attacked. This is generally something attackers try to avoid, since an easily-detected attack is likely to be shut down before it can spread very far.
|
||||||
|
|
||||||
|
### Tricking Creddy into allowing a request that it shouldn't
|
||||||
|
|
||||||
|
If an attacker is able to compromise Creddy's frontend, e.g. via a JS library that Creddy relies on, they could forge "request accepted" responses and cause the backend to hand out credentials to an unauthorized client. Most likely, the user would immediately be alerted to the fact that Something Is Up because 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. 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Most desktop environments don't prevent applications from simulating user-input events such as mouse clicks and keypresses. An attacker could issue a credentials request, then immediately simulate whatever hotkey or mouse click Creddy normally interprets as "confirm this request". To mitigate this Creddy could implement a minimum time for which it _must_ be on screen before dismissal. The attacker could try to wait for the machine to be unattended before executing this attack, but this is chancy and could still result in detection. The request would still be logged in any case.
|
||||||
|
|
||||||
|
### Twiddling with Creddy's persistent state
|
||||||
|
|
||||||
|
The solutions to or mitigations for a lot of these attacks rely on Creddy being able to assume that its local database hasn't been tampered with. Unfortunately, given that our threat model is "other code running as the same user", this isn't a safe assumption.
|
||||||
|
|
||||||
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="creddy">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + Svelte</title>
|
<title>Vite + Svelte</title>
|
||||||
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: grid;
|
|
||||||
align-items: center;
|
|
||||||
justify-items: center;
|
|
||||||
min-width: 100vw;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-zinc-800">
|
<body id="app" class="m-0">
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
2427
package-lock.json
generated
2427
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "creddy",
|
"name": "creddy",
|
||||||
"version": "0.1.0",
|
"version": "0.5.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^1.0.1",
|
"@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",
|
"autoprefixer": "^10.4.8",
|
||||||
"postcss": "^8.4.16",
|
"postcss": "^8.4.16",
|
||||||
"svelte": "^3.49.0",
|
"svelte": "^3.49.0",
|
||||||
@ -17,6 +17,9 @@
|
|||||||
"vite": "^3.0.7"
|
"vite": "^3.0.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^4.12.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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"]
|
||||||
|
|
5698
src-tauri/Cargo.lock
generated
5698
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,26 +1,73 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "creddy"
|
||||||
version = "0.1.0"
|
version = "0.5.4"
|
||||||
description = "A Tauri App"
|
description = "A friendly AWS credentials manager"
|
||||||
authors = ["you"]
|
authors = ["Joseph Montanaro"]
|
||||||
license = ""
|
license = ""
|
||||||
repository = ""
|
repository = ""
|
||||||
default-run = "app"
|
default-run = "creddy"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.57"
|
rust-version = "1.57"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "creddy"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
# we use a workspace so that we can split out the CLI and make it possible to build independently
|
||||||
|
[workspace]
|
||||||
|
members = ["creddy_cli"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
dirs = "5.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
tokio = { version = ">=1.19", features = ["full"] }
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "1.0.4", features = [] }
|
tauri-build = { version = "2.0.0-beta", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0"
|
creddy_cli = { path = "./creddy_cli" }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
tauri = { version = "2.0.0-beta", features = ["tray-icon"] }
|
||||||
tauri = { version = "1.0.5", features = ["api-all"] }
|
|
||||||
sodiumoxide = "0.2.7"
|
sodiumoxide = "0.2.7"
|
||||||
tokio = { version = ">=1.19", features = ["full"] }
|
sysinfo = "0.26.8"
|
||||||
# futures = ">=0.3.21"
|
aws-config = "1.5.3"
|
||||||
|
aws-types = "1.3.2"
|
||||||
|
aws-sdk-sts = "1.33.0"
|
||||||
|
aws-smithy-types = "1.2.0"
|
||||||
|
dirs = { workspace = true }
|
||||||
|
thiserror = "1.0.38"
|
||||||
|
once_cell = "1.16.0"
|
||||||
|
strum = "0.24"
|
||||||
|
strum_macros = "0.24"
|
||||||
|
auto-launch = "0.4.0"
|
||||||
|
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"
|
||||||
|
tauri-plugin-os = "2.0.0-beta.6"
|
||||||
|
tauri-plugin-dialog = "2.0.0-beta.9"
|
||||||
|
rfd = "0.13.0"
|
||||||
|
ssh-agent-lib = "0.4.0"
|
||||||
|
ssh-key = { version = "0.6.6", features = ["rsa", "ed25519", "encryption"] }
|
||||||
|
signature = "2.2.0"
|
||||||
|
tokio-stream = "0.1.15"
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "uuid"] }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tokio-util = { version = "0.7.11", features = ["codec"] }
|
||||||
|
futures = "0.3.30"
|
||||||
|
openssl = "0.10.64"
|
||||||
|
rsa = "0.9.6"
|
||||||
|
sha2 = "0.10.8"
|
||||||
|
ssh-encoding = "0.2.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# by default Tauri runs in production mode
|
# by default Tauri runs in production mode
|
||||||
@ -29,3 +76,6 @@ default = [ "custom-protocol" ]
|
|||||||
# this feature is used used for production builds where `devPath` points to the filesystem
|
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||||
# DO NOT remove this
|
# DO NOT remove this
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
|
||||||
|
# [profile.dev.build-override]
|
||||||
|
# opt-level = 3
|
||||||
|
19
src-tauri/capabilities/migrated.json
Normal file
19
src-tauri/capabilities/migrated.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"os:allow-os-type",
|
||||||
|
"dialog:allow-open"
|
||||||
|
]
|
||||||
|
}
|
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>
|
12
src-tauri/creddy_cli/Cargo.toml
Normal file
12
src-tauri/creddy_cli/Cargo.toml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "creddy_cli"
|
||||||
|
version = "0.5.4"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.86"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
dirs = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
208
src-tauri/creddy_cli/src/cli.rs
Normal file
208
src-tauri/creddy_cli/src/cli.rs
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command as ChildCommand;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{bail, Context};
|
||||||
|
use clap::{
|
||||||
|
Args,
|
||||||
|
Parser,
|
||||||
|
Subcommand
|
||||||
|
};
|
||||||
|
use clap::builder::styling::{Styles, AnsiColor};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
use crate::proto::{
|
||||||
|
CliCredential,
|
||||||
|
CliRequest,
|
||||||
|
CliResponse,
|
||||||
|
ServerError,
|
||||||
|
ShortcutAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
about,
|
||||||
|
version,
|
||||||
|
name = "creddy",
|
||||||
|
bin_name = "creddy",
|
||||||
|
styles = Styles::styled()
|
||||||
|
.header(AnsiColor::Yellow.on_default())
|
||||||
|
.usage(AnsiColor::Yellow.on_default())
|
||||||
|
.literal(AnsiColor::Green.on_default())
|
||||||
|
.placeholder(AnsiColor::Green.on_default())
|
||||||
|
)]
|
||||||
|
/// A friendly credential manager
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub global_args: GlobalArgs,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub action: Option<Action>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cli {
|
||||||
|
// proxy the Parser method so that main crate doesn't have to depend on Clap
|
||||||
|
pub fn parse() -> Self {
|
||||||
|
<Self as Parser>::parse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Args)]
|
||||||
|
pub struct GlobalArgs {
|
||||||
|
/// Connect to the main Creddy application at this path
|
||||||
|
#[arg(long, short = 'a')]
|
||||||
|
server_addr: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Launch Creddy
|
||||||
|
Run,
|
||||||
|
/// Request credentials from Creddy and output to stdout
|
||||||
|
Get(GetArgs),
|
||||||
|
/// Inject credentials into the environment of another command
|
||||||
|
Exec(ExecArgs),
|
||||||
|
/// Invoke an action normally triggered by hotkey (e.g. launch terminal)
|
||||||
|
Shortcut(InvokeArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct GetArgs {
|
||||||
|
/// If unspecified, use default credentials
|
||||||
|
#[arg(short, long)]
|
||||||
|
name: Option<String>,
|
||||||
|
/// Use base credentials instead of session credentials (only applicable to AWS)
|
||||||
|
#[arg(long, short, default_value_t = false)]
|
||||||
|
base: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct ExecArgs {
|
||||||
|
#[command(flatten)]
|
||||||
|
get_args: GetArgs,
|
||||||
|
#[arg(trailing_var_arg = true)]
|
||||||
|
/// Command to be wrapped
|
||||||
|
command: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct InvokeArgs {
|
||||||
|
#[arg(value_name = "ACTION", value_enum)]
|
||||||
|
shortcut_action: ShortcutAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn get(args: GetArgs, global: GlobalArgs) -> anyhow::Result<()> {
|
||||||
|
let req = CliRequest::GetCredential {
|
||||||
|
name: args.name,
|
||||||
|
base: args.base,
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = match make_request(global.server_addr, &req)?? {
|
||||||
|
CliResponse::Credential(CliCredential::AwsBase(c)) => {
|
||||||
|
serde_json::to_string_pretty(&c).unwrap()
|
||||||
|
},
|
||||||
|
CliResponse::Credential(CliCredential::AwsSession(c)) => {
|
||||||
|
serde_json::to_string_pretty(&c).unwrap()
|
||||||
|
},
|
||||||
|
r => bail!("Unexpected response from server: {r}"),
|
||||||
|
};
|
||||||
|
println!("{output}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn exec(args: ExecArgs, global: GlobalArgs) -> anyhow::Result<()> {
|
||||||
|
// Clap guarantees that cmd_line will be a sequence of at least 1 item
|
||||||
|
// test this!
|
||||||
|
let mut cmd_line = args.command.iter();
|
||||||
|
let cmd_name = cmd_line.next().unwrap();
|
||||||
|
let mut cmd = ChildCommand::new(cmd_name);
|
||||||
|
cmd.args(cmd_line);
|
||||||
|
|
||||||
|
let req = CliRequest::GetCredential {
|
||||||
|
name: args.get_args.name,
|
||||||
|
base: args.get_args.base,
|
||||||
|
};
|
||||||
|
|
||||||
|
match make_request(global.server_addr, &req)?? {
|
||||||
|
CliResponse::Credential(CliCredential::AwsBase(creds)) => {
|
||||||
|
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
||||||
|
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
||||||
|
},
|
||||||
|
CliResponse::Credential(CliCredential::AwsSession(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);
|
||||||
|
},
|
||||||
|
r => bail!("Unexpected response from server: {r}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let e = cmd.exec();
|
||||||
|
// cmd.exec() never returns if successful, so we never hit this line unless there's an error
|
||||||
|
Err(e).with_context(|| {
|
||||||
|
// eventually figure out how to display the actual command
|
||||||
|
format!("Failed to execute command: {}", args.command.join(" "))
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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: InvokeArgs, global: GlobalArgs) -> anyhow::Result<()> {
|
||||||
|
let req = CliRequest::InvokeShortcut(args.shortcut_action);
|
||||||
|
match make_request(global.server_addr, &req)?? {
|
||||||
|
CliResponse::Empty => Ok(()),
|
||||||
|
r => bail!("Unexpected response from server: {r}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Explanation for double-result: the server will return a (serialized) Result
|
||||||
|
// to indicate when the operation succeeded or failed, which we deserialize.
|
||||||
|
// However, the operation may fail to even communicate with the server, in
|
||||||
|
// which case we return the outer Result
|
||||||
|
#[tokio::main]
|
||||||
|
async fn make_request(
|
||||||
|
addr: Option<PathBuf>,
|
||||||
|
req: &CliRequest
|
||||||
|
) -> anyhow::Result<Result<CliResponse, ServerError>> {
|
||||||
|
let mut data = serde_json::to_string(req).unwrap();
|
||||||
|
// server expects newline marking end of request
|
||||||
|
data.push('\n');
|
||||||
|
|
||||||
|
let mut stream = crate::connect(addr).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<CliResponse, ServerError> = serde_json::from_slice(&buf)?;
|
||||||
|
Ok(res)
|
||||||
|
}
|
40
src-tauri/creddy_cli/src/lib.rs
Normal file
40
src-tauri/creddy_cli/src/lib.rs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
mod cli;
|
||||||
|
pub use cli::{
|
||||||
|
Cli,
|
||||||
|
Action,
|
||||||
|
exec,
|
||||||
|
get,
|
||||||
|
invoke_shortcut,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) use platform::connect;
|
||||||
|
pub use platform::server_addr;
|
||||||
|
|
||||||
|
pub mod proto;
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod platform {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
pub async fn connect(addr: Option<PathBuf>) -> Result<UnixStream, std::io::Error> {
|
||||||
|
let path = addr.unwrap_or_else(|| server_addr("creddy-server"));
|
||||||
|
UnixStream::connect(&path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn server_addr(sock_name: &str) -> PathBuf {
|
||||||
|
let mut path = dirs::runtime_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||||
|
path.push(format!("{sock_name}.sock"));
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod platform {
|
||||||
|
pub fn server_addr(sock_name: &str) -> String {
|
||||||
|
format!(r"\\.\pipe\{sock_name}")
|
||||||
|
}
|
||||||
|
}
|
35
src-tauri/creddy_cli/src/main.rs
Normal file
35
src-tauri/creddy_cli/src/main.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::process::{self, Command};
|
||||||
|
|
||||||
|
use creddy_cli::{Action, Cli};
|
||||||
|
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let res = match cli.action {
|
||||||
|
None | Some(Action::Run)=> launch_gui(),
|
||||||
|
Some(Action::Get(args)) => creddy_cli::get(args, cli.global_args),
|
||||||
|
Some(Action::Exec(args)) => creddy_cli::exec(args, cli.global_args),
|
||||||
|
Some(Action::Shortcut(args)) => creddy_cli::invoke_shortcut(args, cli.global_args),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
eprintln!("Error: {e:?}");
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn launch_gui() -> anyhow::Result<()> {
|
||||||
|
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(())
|
||||||
|
}
|
91
src-tauri/creddy_cli/src/proto.rs
Normal file
91
src-tauri/creddy_cli/src/proto.rs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
use std::fmt::{
|
||||||
|
Display,
|
||||||
|
Formatter,
|
||||||
|
Error as FmtError
|
||||||
|
};
|
||||||
|
|
||||||
|
use clap::ValueEnum;
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub enum CliRequest {
|
||||||
|
GetCredential {
|
||||||
|
name: Option<String>,
|
||||||
|
base: bool,
|
||||||
|
},
|
||||||
|
InvokeShortcut(ShortcutAction),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone, Serialize, Deserialize, ValueEnum)]
|
||||||
|
pub enum ShortcutAction {
|
||||||
|
ShowWindow,
|
||||||
|
LaunchTerminal,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub enum CliResponse {
|
||||||
|
Credential(CliCredential),
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for CliResponse {
|
||||||
|
fn fmt(&self, f: &mut Formatter) -> Result<(), FmtError> {
|
||||||
|
match self {
|
||||||
|
CliResponse::Credential(CliCredential::AwsBase(_)) => write!(f, "Credential (AwsBase)"),
|
||||||
|
CliResponse::Credential(CliCredential::AwsSession(_)) => write!(f, "Credential (AwsSession)"),
|
||||||
|
CliResponse::Empty => write!(f, "Empty"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub enum CliCredential {
|
||||||
|
AwsBase(AwsBaseCredential),
|
||||||
|
AwsSession(AwsSessionCredential),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct AwsBaseCredential {
|
||||||
|
#[serde(default = "default_aws_version")]
|
||||||
|
pub version: usize,
|
||||||
|
pub access_key_id: String,
|
||||||
|
pub secret_access_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct AwsSessionCredential {
|
||||||
|
#[serde(default = "default_aws_version")]
|
||||||
|
pub version: usize,
|
||||||
|
pub access_key_id: String,
|
||||||
|
pub secret_access_key: String,
|
||||||
|
pub session_token: String,
|
||||||
|
// we don't need to know the expiration for the CLI, so just use a string here
|
||||||
|
pub expiration: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn default_aws_version() -> usize { 1 }
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ServerError {
|
||||||
|
code: String,
|
||||||
|
msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for ServerError {
|
||||||
|
fn fmt(&self, f: &mut Formatter) -> Result<(), FmtError> {
|
||||||
|
write!(f, "Error response ({}) from server: {}", self.code, self.msg)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ServerError {}
|
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","os:allow-os-type","dialog:allow-open"]}}
|
2437
src-tauri/gen/schemas/desktop-schema.json
Normal file
2437
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2437
src-tauri/gen/schemas/linux-schema.json
Normal file
2437
src-tauri/gen/schemas/linux-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
18
src-tauri/migrations/20221201002355_initial.sql
Normal file
18
src-tauri/migrations/20221201002355_initial.sql
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
CREATE TABLE credentials (
|
||||||
|
access_key_id TEXT NOT NULL,
|
||||||
|
secret_key_enc BLOB NOT NULL,
|
||||||
|
salt BLOB NOT NULL,
|
||||||
|
nonce BLOB NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE config (
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE clients (
|
||||||
|
name TEXT,
|
||||||
|
path TEXT
|
||||||
|
);
|
11
src-tauri/migrations/20240612192956_kv.sql
Normal file
11
src-tauri/migrations/20240612192956_kv.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
-- key-value store, will be used for various one-off values, serialized to bytes
|
||||||
|
CREATE TABLE kv (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
value BLOB
|
||||||
|
);
|
||||||
|
|
||||||
|
-- config is currently stored in its own table, as text
|
||||||
|
INSERT INTO kv (name, value)
|
||||||
|
SELECT 'config', CAST(data AS BLOB) FROM config;
|
||||||
|
|
||||||
|
DROP TABLE config;
|
80
src-tauri/migrations/20240617142724_credential_split.sql
Normal file
80
src-tauri/migrations/20240617142724_credential_split.sql
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
-- app structure is changing - instead of passphrase/salt being per credential,
|
||||||
|
-- we now have a single app-wide key, which is generated by hashing the passphrase
|
||||||
|
-- with the known salt. To verify the key thus produced, we store a value previously
|
||||||
|
-- encrypted with that key, and attempt decryption once the key has been re-generated.
|
||||||
|
|
||||||
|
-- For migration purposes, we want convert the passphrase for the most recent set of
|
||||||
|
-- AWS credentials and turn it into the app-wide passphrase. The only value that we
|
||||||
|
-- have which is encrypted with that passphrase is the secret key for those credentials,
|
||||||
|
-- so we will just use that as the `verify_blob`. Feels a little weird, but oh well.
|
||||||
|
WITH latest_creds AS (
|
||||||
|
SELECT *
|
||||||
|
FROM credentials
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
|
||||||
|
INSERT INTO kv (name, value)
|
||||||
|
SELECT 'salt', salt FROM latest_creds
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'verify_nonce', nonce FROM latest_creds
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'verify_blob', secret_key_enc FROM latest_creds;
|
||||||
|
|
||||||
|
|
||||||
|
-- Credentials are now going to be stored in a main table
|
||||||
|
-- plus ancillary tables for type-specific data
|
||||||
|
|
||||||
|
-- stash existing AWS creds in temporary table so that we can remake it
|
||||||
|
CREATE TABLE aws_tmp (id, access_key_id, secret_key_enc, nonce, created_at);
|
||||||
|
|
||||||
|
INSERT INTO aws_tmp
|
||||||
|
SELECT randomblob(16), access_key_id, secret_key_enc, nonce, created_at
|
||||||
|
FROM credentials
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
-- we only ever used one at a time in the past
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- new master credentials table
|
||||||
|
DROP TABLE credentials;
|
||||||
|
CREATE TABLE credentials (
|
||||||
|
-- id is a UUID so we can generate it on the frontend
|
||||||
|
id BLOB UNIQUE NOT NULL,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
credential_type TEXT NOT NULL,
|
||||||
|
is_default BOOLEAN NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- populate with basic data from existing AWS credential
|
||||||
|
INSERT INTO credentials (id, name, credential_type, is_default, created_at)
|
||||||
|
SELECT id, 'default', 'aws', 1, created_at FROM aws_tmp;
|
||||||
|
|
||||||
|
-- new AWS-specific table
|
||||||
|
CREATE TABLE aws_credentials (
|
||||||
|
id BLOB UNIQUE NOT NULL,
|
||||||
|
access_key_id TEXT NOT NULL,
|
||||||
|
secret_key_enc BLOB NOT NULL,
|
||||||
|
nonce BLOB NOT NULL,
|
||||||
|
FOREIGN KEY(id) REFERENCES credentials(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- populate with AWS-specific data from existing credential
|
||||||
|
INSERT INTO aws_credentials (id, access_key_id, secret_key_enc, nonce)
|
||||||
|
SELECT id, access_key_id, secret_key_enc, nonce
|
||||||
|
FROM aws_tmp;
|
||||||
|
|
||||||
|
-- done with this now
|
||||||
|
DROP TABLE aws_tmp;
|
||||||
|
|
||||||
|
|
||||||
|
-- SSH keys are the new hotness
|
||||||
|
CREATE TABLE ssh_credentials (
|
||||||
|
id BLOB UNIQUE NOT NULL,
|
||||||
|
algorithm TEXT NOT NULL,
|
||||||
|
comment TEXT NOT NULL,
|
||||||
|
public_key BLOB NOT NULL,
|
||||||
|
private_key_enc BLOB NOT NULL,
|
||||||
|
nonce BLOB NOT NULL,
|
||||||
|
FOREIGN KEY(id) REFERENCES credentials(id) ON DELETE CASCADE
|
||||||
|
);
|
184
src-tauri/src/app.rs
Normal file
184
src-tauri/src/app.rs
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
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::AppSession,
|
||||||
|
ipc,
|
||||||
|
srv::{creddy_server, agent},
|
||||||
|
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())
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
ipc::unlock,
|
||||||
|
ipc::lock,
|
||||||
|
ipc::reset_session,
|
||||||
|
ipc::set_passphrase,
|
||||||
|
ipc::respond,
|
||||||
|
ipc::get_session_status,
|
||||||
|
ipc::signal_activity,
|
||||||
|
ipc::save_credential,
|
||||||
|
ipc::delete_credential,
|
||||||
|
ipc::list_credentials,
|
||||||
|
ipc::sshkey_from_file,
|
||||||
|
ipc::sshkey_from_private_key,
|
||||||
|
ipc::get_config,
|
||||||
|
ipc::save_config,
|
||||||
|
ipc::launch_terminal,
|
||||||
|
ipc::get_setup_errors,
|
||||||
|
ipc::exit,
|
||||||
|
])
|
||||||
|
.setup(|app| rt::block_on(setup(app)))
|
||||||
|
.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(LoadKvError::Invalid(_)) => {
|
||||||
|
setup_errors.push(
|
||||||
|
"Could not load configuration from database. Reverting to defaults.".into()
|
||||||
|
);
|
||||||
|
AppConfig::default()
|
||||||
|
},
|
||||||
|
err => err?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let app_session = AppSession::load(&pool).await?;
|
||||||
|
creddy_server::serve(app.handle().clone())?;
|
||||||
|
agent::serve(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 !conf.start_minimized || is_first_launch {
|
||||||
|
show_main_window(&app.handle())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState::new(conf, app_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)
|
||||||
|
}
|
||||||
|
}
|
43
src-tauri/src/clientinfo.rs
Normal file
43
src-tauri/src/clientinfo.rs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use sysinfo::{
|
||||||
|
System,
|
||||||
|
SystemExt,
|
||||||
|
Pid,
|
||||||
|
PidExt,
|
||||||
|
ProcessExt
|
||||||
|
};
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
|
use crate::errors::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||||
|
pub struct Client {
|
||||||
|
pub pid: u32,
|
||||||
|
pub exe: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn get_client(pid: u32, parent: bool) -> Result<Client, ClientInfoError> {
|
||||||
|
let sys_pid = Pid::from_u32(pid);
|
||||||
|
let mut sys = System::new();
|
||||||
|
sys.refresh_process(sys_pid);
|
||||||
|
let mut proc = sys.process(sys_pid)
|
||||||
|
.ok_or(ClientInfoError::ProcessNotFound)?;
|
||||||
|
|
||||||
|
if parent {
|
||||||
|
let parent_pid_sys = proc.parent()
|
||||||
|
.ok_or(ClientInfoError::ParentPidNotFound)?;
|
||||||
|
sys.refresh_process(parent_pid_sys);
|
||||||
|
proc = sys.process(parent_pid_sys)
|
||||||
|
.ok_or(ClientInfoError::ParentProcessNotFound)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exe = match proc.exe() {
|
||||||
|
p if p == Path::new("") => None,
|
||||||
|
p => Some(PathBuf::from(p)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Client { pid: proc.pid().as_u32(), exe })
|
||||||
|
}
|
209
src-tauri/src/config.rs
Normal file
209
src-tauri/src/config.rs
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
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::*;
|
||||||
|
use crate::kv;
|
||||||
|
|
||||||
|
|
||||||
|
#[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, LoadKvError> {
|
||||||
|
let config = kv::load(pool, "config")
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| AppConfig::default());
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(&self, pool: &SqlitePool) -> Result<(), sqlx::error::Error> {
|
||||||
|
kv::save(pool, "config", self).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
// }
|
345
src-tauri/src/credentials/aws.rs
Normal file
345
src-tauri/src/credentials/aws.rs
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
use std::fmt::{self, Formatter};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use aws_config::BehaviorVersion;
|
||||||
|
use aws_smithy_types::date_time::{DateTime, Format};
|
||||||
|
use chacha20poly1305::XNonce;
|
||||||
|
use serde::{
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
Serializer,
|
||||||
|
Deserializer,
|
||||||
|
};
|
||||||
|
use serde::de::{self, Visitor};
|
||||||
|
use sqlx::{
|
||||||
|
FromRow,
|
||||||
|
Sqlite,
|
||||||
|
Transaction,
|
||||||
|
types::Uuid,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{Credential, Crypto, PersistentCredential};
|
||||||
|
|
||||||
|
use crate::errors::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow)]
|
||||||
|
pub struct AwsRow {
|
||||||
|
id: Uuid,
|
||||||
|
access_key_id: String,
|
||||||
|
secret_key_enc: Vec<u8>,
|
||||||
|
nonce: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct AwsBaseCredential {
|
||||||
|
#[serde(default = "default_credentials_version")]
|
||||||
|
pub version: usize,
|
||||||
|
pub access_key_id: String,
|
||||||
|
pub secret_access_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl AwsBaseCredential {
|
||||||
|
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
|
||||||
|
Self {version: 1, access_key_id, secret_access_key}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PersistentCredential for AwsBaseCredential {
|
||||||
|
type Row = AwsRow;
|
||||||
|
|
||||||
|
fn type_name() -> &'static str { "aws" }
|
||||||
|
|
||||||
|
fn into_credential(self) -> Credential { Credential::AwsBase(self) }
|
||||||
|
|
||||||
|
fn row_id(row: &AwsRow) -> Uuid { row.id }
|
||||||
|
|
||||||
|
fn from_row(row: AwsRow, crypto: &Crypto) -> Result<Self, LoadCredentialsError> {
|
||||||
|
let nonce = XNonce::clone_from_slice(&row.nonce);
|
||||||
|
let secret_key_bytes = crypto.decrypt(&nonce, &row.secret_key_enc)?;
|
||||||
|
let secret_key = String::from_utf8(secret_key_bytes)
|
||||||
|
.map_err(|_| LoadCredentialsError::InvalidData)?;
|
||||||
|
|
||||||
|
Ok(Self::new(row.access_key_id, secret_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_details(&self, id: &Uuid, crypto: &Crypto, txn: &mut Transaction<'_, Sqlite>) -> Result<(), SaveCredentialsError> {
|
||||||
|
let (nonce, ciphertext) = crypto.encrypt(self.secret_access_key.as_bytes())?;
|
||||||
|
let nonce_bytes = &nonce.as_slice();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT OR REPLACE INTO aws_credentials (
|
||||||
|
id,
|
||||||
|
access_key_id,
|
||||||
|
secret_key_enc,
|
||||||
|
nonce
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?);",
|
||||||
|
id, self.access_key_id, ciphertext, nonce_bytes,
|
||||||
|
).execute(&mut **txn).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct AwsSessionCredential {
|
||||||
|
#[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 AwsSessionCredential {
|
||||||
|
pub async fn from_base(base: &AwsBaseCredential) -> Result<Self, GetSessionError> {
|
||||||
|
let req_creds = aws_sdk_sts::config::Credentials::new(
|
||||||
|
&base.access_key_id,
|
||||||
|
&base.secret_access_key,
|
||||||
|
None, // token
|
||||||
|
None, //expiration
|
||||||
|
"Creddy", // "provider name" apparently
|
||||||
|
);
|
||||||
|
let config = aws_config::defaults(BehaviorVersion::latest())
|
||||||
|
.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 session_creds = AwsSessionCredential {
|
||||||
|
version: 1,
|
||||||
|
access_key_id: aws_session.access_key_id,
|
||||||
|
secret_access_key: aws_session.secret_access_key,
|
||||||
|
session_token: aws_session.session_token,
|
||||||
|
expiration: aws_session.expiration,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
println!("Got new session:\n{}", serde_json::to_string(&session_creds).unwrap());
|
||||||
|
|
||||||
|
Ok(session_creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
let current_ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap() // doesn't panic because UNIX_EPOCH won't be later than now()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let expire_ts = self.expiration.secs();
|
||||||
|
let remaining = expire_ts - (current_ts as i64);
|
||||||
|
remaining < 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn default_credentials_version() -> usize { 1 }
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use aws_sdk_sts::primitives::DateTimeFormat;
|
||||||
|
use creddy_cli::proto::{
|
||||||
|
AwsBaseCredential as CliBase,
|
||||||
|
AwsSessionCredential as CliSession,
|
||||||
|
};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use sqlx::types::uuid::uuid;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
fn creds() -> AwsBaseCredential {
|
||||||
|
AwsBaseCredential::new(
|
||||||
|
"AKIAIOSFODNN7EXAMPLE".into(),
|
||||||
|
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn creds_2() -> AwsBaseCredential {
|
||||||
|
AwsBaseCredential::new(
|
||||||
|
"AKIAIOSFODNN7EXAMPL2".into(),
|
||||||
|
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKE2".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_load(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::fixed();
|
||||||
|
let id = uuid!("00000000-0000-0000-0000-000000000000");
|
||||||
|
let loaded = AwsBaseCredential::load(&id, &crypt, &pool).await.unwrap();
|
||||||
|
assert_eq!(creds(), loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_load_by_name(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::fixed();
|
||||||
|
let loaded = AwsBaseCredential::load_by_name("test2", &crypt, &pool).await.unwrap();
|
||||||
|
assert_eq!(creds_2(), loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_load_default(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::fixed();
|
||||||
|
let loaded = AwsBaseCredential::load_default(&crypt, &pool).await.unwrap();
|
||||||
|
assert_eq!(creds(), loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_list(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::fixed();
|
||||||
|
let list: Vec<_> = AwsBaseCredential::list(&crypt, &pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to load credentials")
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, cred)| cred)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(&creds().into_credential(), &list[0]);
|
||||||
|
assert_eq!(&creds_2().into_credential(), &list[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// In order to avoid the CLI depending on the main app (and thus defeating the purpose
|
||||||
|
// of having a separate CLI at all) it re-defines the credentials that need to be sent
|
||||||
|
// back and forth. To prevent the separate definitions from drifting aprt, we test
|
||||||
|
// serializing/deserializing in both directions.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_to_app_base() {
|
||||||
|
let cli_base = CliBase {
|
||||||
|
version: 1,
|
||||||
|
access_key_id: "AKIAIOSFODNN7EXAMPLE".into(),
|
||||||
|
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&cli_base).unwrap();
|
||||||
|
let computed: AwsBaseCredential = serde_json::from_str(&json)
|
||||||
|
.expect("Failed to deserialize base credentials from CLI -> main app");
|
||||||
|
|
||||||
|
assert_eq!(creds(), computed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_app_to_cli_base() {
|
||||||
|
let base = creds();
|
||||||
|
let json = serde_json::to_string(&base).unwrap();
|
||||||
|
|
||||||
|
let computed: CliBase = serde_json::from_str(&json)
|
||||||
|
.expect("Failed to deserialize base credentials from main app -> CLI");
|
||||||
|
|
||||||
|
let expected = CliBase {
|
||||||
|
version: 1,
|
||||||
|
access_key_id: "AKIAIOSFODNN7EXAMPLE".into(),
|
||||||
|
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(expected, computed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_to_app_session() {
|
||||||
|
let cli_session = CliSession {
|
||||||
|
version: 1,
|
||||||
|
access_key_id: "ASIAIOSFODNN7EXAMPLE".into(),
|
||||||
|
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
|
||||||
|
session_token: "JQ70sxbqnOGKu7+krevstYCLCaX2+alUAT60ARTBBnQ=ETC.".into(),
|
||||||
|
expiration: "2024-07-21T00:00:00Z".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&cli_session).unwrap();
|
||||||
|
let computed: AwsSessionCredential = serde_json::from_str(&json)
|
||||||
|
.expect("Failed to deserialize session credentials from CLI -> main app");
|
||||||
|
|
||||||
|
let expected = AwsSessionCredential {
|
||||||
|
version: 1,
|
||||||
|
access_key_id: "ASIAIOSFODNN7EXAMPLE".into(),
|
||||||
|
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
|
||||||
|
session_token: "JQ70sxbqnOGKu7+krevstYCLCaX2+alUAT60ARTBBnQ=ETC.".into(),
|
||||||
|
expiration: DateTime::from_str(
|
||||||
|
"2024-07-21T00:00:00Z",
|
||||||
|
DateTimeFormat::DateTimeWithOffset
|
||||||
|
).unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(expected, computed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_app_to_cli_session() {
|
||||||
|
let session = AwsSessionCredential {
|
||||||
|
version: 1,
|
||||||
|
access_key_id: "ASIAIOSFODNN7EXAMPLE".into(),
|
||||||
|
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
|
||||||
|
session_token: "JQ70sxbqnOGKu7+krevstYCLCaX2+alUAT60ARTBBnQ=ETC.".into(),
|
||||||
|
expiration: DateTime::from_str(
|
||||||
|
"2024-07-21T00:00:00Z",
|
||||||
|
DateTimeFormat::DateTimeWithOffset
|
||||||
|
).unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&session).unwrap();
|
||||||
|
let computed: CliSession = serde_json::from_str(&json)
|
||||||
|
.expect("Failed to deserialize session credentials from main app -> CLI");
|
||||||
|
|
||||||
|
let expected = CliSession {
|
||||||
|
version: 1,
|
||||||
|
access_key_id: "ASIAIOSFODNN7EXAMPLE".into(),
|
||||||
|
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
|
||||||
|
session_token: "JQ70sxbqnOGKu7+krevstYCLCaX2+alUAT60ARTBBnQ=ETC.".into(),
|
||||||
|
expiration: "2024-07-21T00:00:00Z".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(expected, computed);
|
||||||
|
}
|
||||||
|
}
|
116
src-tauri/src/credentials/crypto.rs
Normal file
116
src-tauri/src/credentials/crypto.rs
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
use std::fmt::{Debug, Formatter};
|
||||||
|
use argon2::{
|
||||||
|
Argon2,
|
||||||
|
Algorithm,
|
||||||
|
Version,
|
||||||
|
ParamsBuilder,
|
||||||
|
password_hash::rand_core::{RngCore, OsRng},
|
||||||
|
};
|
||||||
|
use chacha20poly1305::{
|
||||||
|
XChaCha20Poly1305,
|
||||||
|
XNonce,
|
||||||
|
aead::{
|
||||||
|
Aead,
|
||||||
|
AeadCore,
|
||||||
|
KeyInit,
|
||||||
|
generic_array::GenericArray,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::errors::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub 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;
|
||||||
|
|
||||||
|
|
||||||
|
pub 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn random() -> Crypto {
|
||||||
|
// salt and key are the same length, so we can just use this
|
||||||
|
let key = Crypto::salt();
|
||||||
|
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
|
||||||
|
Crypto { cipher }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn fixed() -> Crypto {
|
||||||
|
let key = [
|
||||||
|
1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
||||||
|
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
||||||
|
];
|
||||||
|
|
||||||
|
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
|
||||||
|
Crypto { cipher }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn salt() -> [u8; 32] {
|
||||||
|
let mut salt = [0; 32];
|
||||||
|
OsRng.fill_bytes(&mut salt);
|
||||||
|
salt
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec<u8>), CryptoError> {
|
||||||
|
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
|
||||||
|
let ciphertext = self.cipher.encrypt(&nonce, data)?;
|
||||||
|
Ok((nonce, ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt(&self, nonce: &XNonce, data: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let plaintext = self.cipher.decrypt(nonce, data)?;
|
||||||
|
Ok(plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for Crypto {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||||
|
write!(f, "Crypto {{ [...] }}")
|
||||||
|
}
|
||||||
|
}
|
19
src-tauri/src/credentials/fixtures/aws_credentials.sql
Normal file
19
src-tauri/src/credentials/fixtures/aws_credentials.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
INSERT INTO credentials (id, name, credential_type, is_default, created_at)
|
||||||
|
VALUES
|
||||||
|
(X'00000000000000000000000000000000', 'test', 'aws', 1, strftime('%s')),
|
||||||
|
(X'ffffffffffffffffffffffffffffffff', 'test2', 'aws', 0, strftime('%s'));
|
||||||
|
|
||||||
|
INSERT INTO aws_credentials (id, access_key_id, secret_key_enc, nonce)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
X'00000000000000000000000000000000',
|
||||||
|
'AKIAIOSFODNN7EXAMPLE',
|
||||||
|
X'B09ACDADD07E295A3FD9146D8D3672FA5C2518BFB15CF039E68820C42EFD3BC3BE3156ACF438C2C957EC113EF8617DBC71790EAFE39B3DE8',
|
||||||
|
X'DB777F2C6315DC0E12ADF322256E69D09D7FB586AAE614A6'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
X'ffffffffffffffffffffffffffffffff',
|
||||||
|
'AKIAIOSFODNN7EXAMPL2',
|
||||||
|
X'ED6125FF40EF6F61929DF5FFD7141CD2B5A302A51C20152156477F8CC77980C614AB1B212AC06983F3CED35C4F3C54D4EE38964859930FBF',
|
||||||
|
X'962396B78DAA98DFDCC0AC0C9B7D688EC121F5759EBA790A'
|
||||||
|
);
|
42
src-tauri/src/credentials/fixtures/ssh_credentials.sql
Normal file
42
src-tauri/src/credentials/fixtures/ssh_credentials.sql
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
INSERT INTO credentials (id, name, credential_type, is_default, created_at)
|
||||||
|
VALUES
|
||||||
|
(X'11111111111111111111111111111111', 'ssh-plain', 'ssh', 1, 1721557273),
|
||||||
|
(X'22222222222222222222222222222222', 'ssh-enc', 'ssh', 0, 1721557274),
|
||||||
|
(X'33333333333333333333333333333333', 'ed25519-plain', 'ssh', 0, 1721557275),
|
||||||
|
(X'44444444444444444444444444444444', 'ed25519-enc', 'ssh', 0, 1721557276);
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO ssh_credentials (id, algorithm, comment, public_key, private_key_enc, nonce)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
X'11111111111111111111111111111111',
|
||||||
|
'ssh-rsa',
|
||||||
|
'hello world',
|
||||||
|
X'000000077373682D727361000000030100010000018100C4ABCE6D69400912EBAD527733401E30EBF3DC9433B79C8E343D7AFBE19A9F309934822577D9807346B48D4FB0604D022DA826E5624635E4CE19851AA5D30DFD2007DE99B04AE4C2F00823DFFC3C8DDE62F074831C1F8903067C83DCCD7D9CEE8643C93C5291F6B5047F53646A37C84098934FFDE5882B5DD7696CDDC4421C39E2894768CFD6650CE585E35A3F739B015650AA469ABDEFC6987E55DAFEC7D40B4388654ED3205D18528D881927C42CBE210CCF6F49A90619AD6E6ACBF1768D7EC52FF9CB85BE607B9414961566292016875164C1C1D1FBD4C3569D4424A7F19D043ABCDEE50573DFC4FC7F2C2718AA76528FA226C0DD5530DC705C30901E1BDE88FE5CC35CAE5AB8826D1E7F970DBED0A0F7E9833CFC7323A1F1323528D5CC3C00AEB98165D677CAF64BD69729132264D971B5C491D0AEAF53AAD22D03756B2E43754502E84488117EEBB962CCDF5DF59682C1E9BA472D5AB9B83DB2862E7EA380E8FD20DE9368CABCBBC5C95C233A52DE5DFE5E91CB59019D00B529C70C4305',
|
||||||
|
X'DB9B6A3B97FBAE6AC12BDAF9DA57DBEE4DDF6A92DD682958AF147FF5EF64C18255D2A1714D543F2D16BEFD7ED4C419C7A0E9C18754C4CAA251BCFA5AA46508B006CDB08A7C0DB63D8A7FE27F99CCC2F351203B36D2BC3D02302318ECC741574CEF70D956C5CCA41E538F2CA29B20E04778A596B0C3E5CD991A423443B01E3F811E004E2547C5D3DDAEBCFBFB68CDB03D0C16538224BBAA0A80767D64D8F3D2840975DD12B4F648F81B4D4B541CB500BAA99F9808F450A02688D583A924B8AE2B0BE777BC35CE808FD53B5DB8C0838D24A6CE31C3973880CF3174E63E3404F2E77783140A62DDBA06F9CD89ADE448A54FBCBD6C0EC8C0641724CDDEF2A8126EC0D0F5BDF89EB8112366D7EB6D3CE3565DF9E4036EAF3109E50BED5D7BD3558FFF69AD823F6522C5701CE26BCAFCC03D27D87547728A3C700719FF564EAEC961FA209252B113B404D75AD67CA4E40C5DAA36E9B0FB4ECDD6E5F853C81682123E8DF311A3C495F61A2CEE6A2B04C7FD3D0906583B9C724BC0D00D71106B7167983D6A0FBD3EA7361EE063A0B05E5A6B5CD82D0F795820BFC90E4F422E7CE2BDEBEAEE9493F5408F38732EB41741F15632185131CD6160433DD286869DF38679F6797A268EDE8ED0F442C4394FF52EFDE82EBEC5871A087288F7A12964615DA5AA02149FB661B0F76551CD53771B0DD180837A9D52A2BDF4757C4CF56DCD90B968F32B9F9EE5EE09EF5B791DB0366A4E6CCBBF0AF7D9CB5B7760BABAF4DE16BCC971DC95DCBD068A92DB8E8C709C0FBE9E2AB5B770EDFBCC6FB5045B706FB31DDEB6C52647618CD3B222CEF2DFD8D08ABAE6333A2E3C8768B8DB970BFF1777B75AE6DCE54DA7063F76846EFBDB92E55192A031DBA889D9DEDB0BF0FAA2FA6B4A0B0151B6F03D142D6B140EBB874CAF0A44D67AAD121127946DA90A14176EBB7B6C03DD2034987A100855E23F440CF6A404DEE46617B52581C7A248B7393FF56D8652855B23D19C35E1B535E5EC5EE87F3FF455458A740A55CDCB806053D4BCF44CDC2D76A1998418A60E11728BBC69F12C7E52A539E3834362C47A3E1863D265B3A7C2A41FA1953BB0FC64508679BB5F068DAF84C394A1497D564A3D6023B90D9A1C50E30FDC3E1C9B925EB0C19F960E7377B0678D662362129677E4B9AA515D2E4408A17A260D862F3C5D4291841855B91FE6EEA11C8E8EC19449CD9C31E6505BA364A45E7E3B89C5FA1C55708AF521F97440CED0ED0FAF06B7930E6A6F3A2B547E33EF73163D4C2E75B1AFA24BEB3129FFA978BC4EC43D0919ED262C0BE29AB78A87A57EAA55D51BE479A9E4015F9C3F2381745808AECF3783DEF5AB82E37C6EF68B97485CD36F7018B59C37E0EB93EAA32385E5E8CB95A5A3818B70F4CBE6102FC197946AAAAAABAD8B93750031CAC73C3F1B6B2F825B29435F2426B6AFABE35B1F8468E5A1CF73CC78E2FBB639AEFC171B7AD5D1728A536AB384B3F4AE924D5CEFA3F5EE5412094AE97303B8E728C7ACBCD9F9FB7C4FE7893145A55D96B7EDC1DD6257368C03AAC98B4F23D9AF15EF730BFD3FF09C2A11747035C8FD58EF97003503F568090C02A63117F3304989CFBAF20A281A729C8A8A4470524B3FDD2B4183E78BDE58BBB0B58B16D1E81702E58E225F7EED1A8E7F3920870FC9EE44D1433EEB39248A38108000EC1E151A26399A3F36CC41F6D272B3441198E8B56616E9A6C5A16303E562A62B4E6C27D16E9FADAF7E5A4AC7EFBD912883474302D5C9BB7D35C671DDECE68482A9472DAFA56B9AFF4E811A5BE7462FA6A988FED04178786DDB490A2010B8C178BD5601C23BBF5E3B1D13E86BB9980892B9999A6511FE2ECDAC681123745F676C155BB4627EFFAA65B1110B590A7FEE6D3359AED898D73C1B51AC8D534E94731934CCA9514F89E74C2BE5A799D8072C52399A7A647AF8F37F2D536C1B29D64214C490FE00565D912772256BF5E68F888E02FB704017F4D9FDD22E1C007A5FB4FCB51BC7A101DCAA56529231A59ACE14368268B7820C7C2BFBB0F5F78625E442C6EA83C88A9DEE318B2323AF0F3687ACF7B2B791D0B42B0576F0FF73E046DE1A56A5C2CBF6731E8D9485A02E9AD67D7752EBDBF3EBE703A760264363650CB9639B75985A9D00D210FFEBC93894E8E4BEAE7053FB6619BA9A8F0ECC4F822CF27606A6E58A8D5DAF55B519E7729B65A83FB859A3A028477BFBB7C8C01ABBE38EEDAAE11AA10ECC75868A281281792FA8D4EBDCC47DEB03868779A84D992D56612A8F46CAFADF65C5B32CFEA2974ECE34E4EDE9AF0AB4365C55D1A95FE551453BCFA5DF28CAF5AFA025CD5BD1CF86FB19AEB581135BFE2CBAA78643F209DE6A4D58206B0B236ADDD5A9122E8A21630907D0C5F23E86C151B8BFD8EA874DFF37DA7DE49D520DDAB7D074B37A726883211A788684A74D4E13A80CBC7655D8BBFF901CC44EF0A0368A3A69200695E277857AD620F2872D83224405A4DDD1E34AF68B72145AA442278C02DB7453AA8C184893AEBBCD4E15252CB8AF5972C49E047318362322CFAE99C38C5989A76C57E9D997BAACF6E13C19F66FCD618878D218DE7846C3D042E7E631B9AC126935AD6A3E15A659A3C4B9B5E521545A5A9B8A3CDFD21EADC2A5A74DBFA0769D63EC4F758D',
|
||||||
|
X'1A44F10CBD2579B378EF1ECE61005DBD0ED6189512B41293'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
X'22222222222222222222222222222222',
|
||||||
|
'ssh-rsa',
|
||||||
|
'hello world',
|
||||||
|
X'000000077373682D727361000000030100010000018100B021E0FE494231E75D4CFC9CED6DF524122F0E86717710BB066236D1ABF001CB4C7CB58964E998E5385836912300129A1334E549A7EF5E0EC4115D97E099038ACFBBA0AD2FE5D574F7F3FF122A97B59F75D8B62DCD921FF1A5BBFDCB55D77779A41ABD46528AEF8B2C0DA96370FCEC79387EED6AC1C0CED041AE979CBB880BEC6C17917711143F1C4D035548D273773D01E3F643463811B7339D9F4B3FC8D1FECF761C8878C135E2E600D9D230F11A3AD8E0415D1A923A398D108E9043F630A9B7BB1310927CA8A46455096E1A272BA56B6F06FEE5764E3C8AC85EA5DE408AED8EC549BE749FB231C1A2CC95DA0035DB009A9DFB2C622833A54CFCCB9FFB173159065F3335C6DDAFBB52A82CD5C327198C496C2A4404F1A544D82175F915954492A4488954B37C78C1F81B467A05F96CCC26146CDF517AF71674046947B11CE80B0E277B2ABA23915AF11E9A9F9D05717E1F0ED70341F470085569F88D8F5CBB8179605A0BF88537A57893329D15F1F8CA3582BE3612410F06568533F801602F',
|
||||||
|
X'AD54C319103CFBA088A4B70AEC743CDED7B0A3EE3DDE370BBD14AA4FA4EACBDD1BBE2FCEF499BB4EC4DDEA9D472F27BBF93453C612BF1689B714C9718212E78C0E1B2133AEB0E7C954413F6EBFC4155CC975A252962AB7C1BBEAE8DA8C6F990B9DE96313F0158AA8DD7896000AE2A4406080B81C37605B3986E463D5DEC01AC0BC4981A74BAF6413DF99119F65A337692885E9C5FBA9B483AA83783823981A0E66105083EB6CDB07AB93714AAF6AAF9A6239D256D8C9C56992AC846CF104E2B1B9DF96D0E67DF2EC9258E914EDFAA5AC36ABE3E9D5D641C92C6188D90D9B083DC3AFA9409B7809718279B52399145FE3173DA8E8A7E5C21715E0B140B22BFF8A0116E102B55C9BCB19B5B4FCCA88FBC5A2844E7E2AEC84ACA303BE8AC9448F93BB35366DFC2E38CEE31C66748847DA11CBB8A31F2CA4DD905362A8C513B6B8E3040EFCEC5BCFEB2E52902F33BF6DFEF911E56A00E51274C1548546DCA62261F94F580DCBAF7357F0C8F5058D2D1D5C91E0AAAB396A305E79350FC0F9879CAFD33316DB77586C36A8246F4D5A14EEC495CFCFA108B70A00008CE64ABC2EBC656DFE760612194B526BC1AE8C08325E3FE76999E6341D6C2BA35BA87CB9FB30A269891A0013E989246E80D5CF7590A66D8494CA79D5E2FD6A8FF7ECA1169379C2D45F4108BA5A796309D4CBDBEE6F45A0F6536B45666E1CF977B26612BC8108FCF32FF0D9296C9C414812C221032B2E5107CFCE1E4FCC2E07C5D31F1A1492732D0ECEB3920E50DFBCAA89561CE52436D23DA40D8678CE901BDC57C3F80233BBAF7AE5CA432547EB51DFBABF5B8BC94C0F6EAE47C94649CECE192D6436D609EE040A3AC059529E7CAAFA45D1B2E331E0E73BAFB1C6E05F71EBF28E222D2B15E724D5EBB3B9C3A709F0F9BCD41C87DF158BDCD3C1FCA86A8D4B57B98F4386AA6956BC3DC6BC2AF6A479560C1598B866935795C29F22CB93072E9D8D4D110AAC2B0F22CD8662354BF5D509750068613C052E88629EFF9488BD1C0B3E6FFD010A5B739F943234AA456F998B4DC7FA7B877961DE1CC744760712337B70971EED7AA4B97121F26298DCFDC2282D721CAB90098585ACFD31EC776EEE2C0211AB711BE94F31ED0D2BA4A9D8EFFC155FC68AB02EA1DC380A1525EF2BD14B55CC71210B54E5F55A8C3C876A6667EFA271095B1280B9ED6FAE9E73601A698FE2732756780BF453F927FD171F497F9C1FA6ADE7DC8187FEDA309E807E2E7895E1763DE1758E50035CE24D54A814745F05446FFD91F8E27770577384BBDC6E11E435658404533D32C461A0DB1CC6AE0847ABB744FB61C524CF9162E3660941CA3DA96F56EA5C036BF5E633C6CA0F033335AB5B623D08A024E87235FF8324B284FB981A9998DD0028A0DE54B4D6BE04C51E8D71DD09B3563C84E5C43826418365FB7912DFABDC5BB25BDE2C558DAC14AEED79F705F34E2D04F17829515C725675571EE1E4BDD21D8EBAA9C6075DE48EF8F2ED7814E20836A2721E46B5C71EE365CFE996A07ADABD84FE5B1E25EF5D9CC66B945084A4207004372AA792BA1BE97B67397635EA7DCC2F99C6AD5C394A8F4C0B7CBC87C38DE52F120993E6DC6BEA27D5B90D90E1C8F7626C860386121E53BE3D4F7B4005A69EB0334E118E70B7207CEDFCEE1EC2A30C789174AAA6531EAAD2E0BED7400CA44911E896E4C82DCA85ADBA92CA01B2CE75924AE81FE286C4CBD8073B7546313A75E52CA1882D8935D2F6058FECEBA4626B4445FCED2E9986632F9F5597C7BBD44F375027727D51B0033B87D23395EEC26EE06378B247B0C1469286F868828C942FCE2BF1BFFDC07CAEC1E214D37ADE737A7DFF082972C6E8411591BEE4B54BED231A7F856C022B26887ADD115A252807D3C58DCD8FB5D7D71DC3766C288438DB3D9D98FC8A22FEC92A7E6E3855ACD36BBEA79C5F98C7ABD9CCECB37C18C3315E5CC7B3BFF699FD201419F8EA402E422EFE62A25D4A76B2CCA0F6D43313BA7DF6537619FD2AE8ACF55B17F709961228076DBBB3592B6B7A1C3C271D54C06403902B0384492AE486E931DD63F68E739769E174D97EA46E7D780D03529EA21B418E0A68E44ED15AB9471B5F139E29EA25C7AE881E216A2863D6E908790002B0B1CC23B1DB3266CEACA2771BD661941AEDEE196316E8D8D7CE361C23E7C1BFFBFD0467329E948CD936B54C7313DC053F96BAFD139500ACB0CCDCA7C0AFBFEC02CFD31FECF4193C1E13F8E59378959BE3360C3B57BD325E5D87CA3D9CE08EFD00980200004D01EE4C6D4450C545A82BE0E1A527AE3432AD6500AF6C8B4400095D9CA7DAA0AA956DC8CD6A4336B876988128119997DB4847AFEFDF2CB8E3F88D5B66CC1E5A32229F79324063584C95C775C5D8D3B05956F0BC8432B9FB28D006247F1DF22C431515BDB4234C91B10CA20B5C05924CAAF82094C8C49123776F1C7170218FEC6C1D2D94F242277765EB9A6C48BE8751D92FFC4C3314155C7685940CA07BB70722D0B65585BC50253A9A6F793CD7A3269657B234C72EC8F2DD4F3B61A7260B3028FFB2B866A311E027C3D8D56592AB4795AA22452CBE37AEE68D7952EE473BB67CE6839E0F5DAA7C9B09F26CBF99CF5BF1181A41B683B9EA939A1823C3733B1EE8066614D3A692C99E5F9EA22231',
|
||||||
|
X'B9DF74AE34E4E7E17EA2EABECE5FD85B14ADB53EDB5BF27C'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
X'33333333333333333333333333333333',
|
||||||
|
'ssh-ed25519',
|
||||||
|
'hello world',
|
||||||
|
X'0000000B7373682D6564323535313900000020BBB05846908A7F4819CA69BE50E94658FD6F51D24FFECED678566D43E1DD6BF2',
|
||||||
|
X'7E3719254AB02100F159D971C17322CF51ECB60AC9E2CDA511EDFD88E75D9828A5A308F1F6A7D6919ED080FD0E6D3FAB64583A946334EE8870006AB7EDC57E6D7BCD145485D1F2A06D946B4DB69591467F289A5CD3BBF922FAAF5B54275F56CF81CC450DE4C8C0F24078C395BA02E8C646731FA6A50480392B13784FD2A85D094DDB8E73C56120936C02C3F94E910C23787FC307369239E264600BBE799EA851CE16FD653BE71D024AA73A582AACC390DD1F341C095788ECE6F4CC37D045A2BFFEC9F14AEBE73E43C6E78E00A9645C6A46D03F2847355DBCD33DA09C76148089A0FC1B3793AB5DA577B879D25EF7B8A8661387F19F392522CFD2886F6FEB65584841',
|
||||||
|
x'58E67EEE49A11FFDD9D32F63ED99053008091B415F87F1BA'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
X'44444444444444444444444444444444',
|
||||||
|
'ssh-ed25519',
|
||||||
|
'hello world',
|
||||||
|
X'0000000B7373682D65643235353139000000200491C64AD1D7E9C20D989937677C32EBE5FB35BCBA77422550A8FAA54C023923',
|
||||||
|
X'6BA994C263935729D807579173B377323F6353A88F660143EA92DE1E1A92F00682B8A1FAD838F0D211BD69855E8E34AE84D5A7B3C23F23A822B2AFF6E861BC81D89AFDDEB0DED063C84644B3EFEF2612DA1DA9C3C12EDAEFCBEA3542EA0ED1903FC1922E5F56E19FAD8CC75A2A30D64C83BF27ADE00E66BCCFE1CA67E95A00819F7BF91DDD22C4A1FB419E91B5D61544175D8D69EB5B416E6547DFD55CD386B62293B778322FB840D1F4DBBDCE2364A6FE4A7B090425031E7DB347314CEBD9BA09F85CC45CF3B4D02FE78B7F365D5C7E95331AA7A6F91A619E8A8663B77A31BAF639652D72B4FD11C8D430C8A1C5542C69DF4ACA74BAB7608B7E9ADD15BAF4674AFB',
|
||||||
|
X'46F31DCF22250039168D80F26D50C129C9AFDA166682C89A'
|
||||||
|
);
|
8
src-tauri/src/credentials/fixtures/ssh_ed25519_enc
Normal file
8
src-tauri/src/credentials/fixtures/ssh_ed25519_enc
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABAWtYanP1
|
||||||
|
TBKT8lBL4IzKpYAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIASRxkrR1+nCDZiZ
|
||||||
|
N2d8Muvl+zW8undCJVCo+qVMAjkjAAAAkI021XFPzB9VnO8uGAQ8f3bwP/ki5fDVuWD7Fc
|
||||||
|
crN+yfT8Ugjhc7IL2dIt/xj9iJIa9fJDw0pg1Y8issqp9C8HVhasyWpf2iwJIalUHTOekn
|
||||||
|
WdoxA+/OQBstRBKSv43sI801+9OC8dXCMNM2QzpiGNs0QxdLJpcJQhHEvqq/yDIODF0p7M
|
||||||
|
h3e9eYGVPOR0CjlQ==
|
||||||
|
-----END OPENSSH PRIVATE KEY-----
|
1
src-tauri/src/credentials/fixtures/ssh_ed25519_enc.pub
Normal file
1
src-tauri/src/credentials/fixtures/ssh_ed25519_enc.pub
Normal file
@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIASRxkrR1+nCDZiZN2d8Muvl+zW8undCJVCo+qVMAjkj hello world
|
7
src-tauri/src/credentials/fixtures/ssh_ed25519_plain
Normal file
7
src-tauri/src/credentials/fixtures/ssh_ed25519_plain
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||||
|
QyNTUxOQAAACC7sFhGkIp/SBnKab5Q6UZY/W9R0k/+ztZ4Vm1D4d1r8gAAAJAwEcgHMBHI
|
||||||
|
BwAAAAtzc2gtZWQyNTUxOQAAACC7sFhGkIp/SBnKab5Q6UZY/W9R0k/+ztZ4Vm1D4d1r8g
|
||||||
|
AAAEB9VXgjePmpl6Q3Y1t2a4DZhsdRf+183vWAJWAonDOneLuwWEaQin9IGcppvlDpRlj9
|
||||||
|
b1HST/7O1nhWbUPh3WvyAAAAC2hlbGxvIHdvcmxkAQI=
|
||||||
|
-----END OPENSSH PRIVATE KEY-----
|
@ -0,0 +1 @@
|
|||||||
|
{"algorithm":"ssh-ed25519","comment":"hello world","public_key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILuwWEaQin9IGcppvlDpRlj9b1HST/7O1nhWbUPh3Wvy hello world","private_key":"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACC7sFhGkIp/SBnKab5Q6UZY/W9R0k/+ztZ4Vm1D4d1r8gAAAJAwEcgHMBHI\nBwAAAAtzc2gtZWQyNTUxOQAAACC7sFhGkIp/SBnKab5Q6UZY/W9R0k/+ztZ4Vm1D4d1r8g\nAAAEB9VXgjePmpl6Q3Y1t2a4DZhsdRf+183vWAJWAonDOneLuwWEaQin9IGcppvlDpRlj9\nb1HST/7O1nhWbUPh3WvyAAAAC2hlbGxvIHdvcmxkAQI=\n-----END OPENSSH PRIVATE KEY-----\n"}
|
1
src-tauri/src/credentials/fixtures/ssh_ed25519_plain.pub
Normal file
1
src-tauri/src/credentials/fixtures/ssh_ed25519_plain.pub
Normal file
@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILuwWEaQin9IGcppvlDpRlj9b1HST/7O1nhWbUPh3Wvy hello world
|
39
src-tauri/src/credentials/fixtures/ssh_rsa_enc
Normal file
39
src-tauri/src/credentials/fixtures/ssh_rsa_enc
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABAanK91R1
|
||||||
|
FN66oOcvNyslkhAAAAEAAAAAEAAAGXAAAAB3NzaC1yc2EAAAADAQABAAABgQCwIeD+SUIx
|
||||||
|
511M/JztbfUkEi8OhnF3ELsGYjbRq/ABy0x8tYlk6ZjlOFg2kSMAEpoTNOVJp+9eDsQRXZ
|
||||||
|
fgmQOKz7ugrS/l1XT38/8SKpe1n3XYti3Nkh/xpbv9y1XXd3mkGr1GUorviywNqWNw/Ox5
|
||||||
|
OH7tasHAztBBrpecu4gL7GwXkXcRFD8cTQNVSNJzdz0B4/ZDRjgRtzOdn0s/yNH+z3YciH
|
||||||
|
jBNeLmANnSMPEaOtjgQV0akjo5jRCOkEP2MKm3uxMQknyopGRVCW4aJyula28G/uV2TjyK
|
||||||
|
yF6l3kCK7Y7FSb50n7IxwaLMldoANdsAmp37LGIoM6VM/Muf+xcxWQZfMzXG3a+7Uqgs1c
|
||||||
|
MnGYxJbCpEBPGlRNghdfkVlUSSpEiJVLN8eMH4G0Z6BflszCYUbN9RevcWdARpR7Ec6AsO
|
||||||
|
J3squiORWvEemp+dBXF+Hw7XA0H0cAhVafiNj1y7gXlgWgv4hTeleJMynRXx+Mo1gr42Ek
|
||||||
|
EPBlaFM/gBYC8AAAWQf6woBjAp1r47e3HsH4DyTDNF+u98eyCXLb86Lf8G9IFzOACMx4Bh
|
||||||
|
auNdB2dZ/Re2FZ6bdzb+h9snQf0PY4y4zJ7bmJ5VbRcYAM/XnVcKP+Q2254te15DLAsKXA
|
||||||
|
rzGVdEB8vshTloEHZTBVGiWRSFvn/rzPTNRhw5X/OMX21EAFR2yFXFHSxKwuPTWRCTTan3
|
||||||
|
PA7BqJX8k6XtzwafPo9as0ui3jds/aL9VBlxlQB3x5uWfo7Kw73qReDzaIS94VVsm667tI
|
||||||
|
KIN/0/e3mDpfXmWLH2Xc7BLZcs5eSHztwakYDPc5VzFTdAfb4juVdVmiLUs0ttj+aXnJo9
|
||||||
|
6p/kX5ISSs5gzAaL2yGmPjNeeEXgV38ysYnNUB0fIoceuda54oM8kYAeZnQGpgV0Rh6ku+
|
||||||
|
KNWajrJF22cH6QQ61VO4ymoDrw+oxyTog/M5n7IhCROGAJOQV4CRYKELHwMIt6niiihDfI
|
||||||
|
+YbIs7Qs0ap4mHeVKbLS3WsSK7mZI70yCeLzT+ilNaqW28RLHxAEM86lRfuH1vmABKdy8D
|
||||||
|
3e1K0WivbY5zmGvFGP1DIl3NXr6M7ZaFg5bgohssOXzMucAOR9mZpzMg20jF4SOt7IC9SU
|
||||||
|
pWg+OIIP7pVfS2FjATMrh25xgeqD2BcDSoJWEH4xrlviyBS1wVA9W35npHiJSQptppn8cj
|
||||||
|
EhwuS916OMhWOsXHPssqHFA+DrLByCZKcORD/mFPpsnI4/3TvA4PL6pqv2Kup0YBDqkyko
|
||||||
|
wIyZQMjr4DjR6xYR3W0Mjzn2UG0Grn96QGrjnj1l/LAXAw00NeYktI4m5YX4wIIdhP/RT8
|
||||||
|
RL9d4SE0YicneoDPtcLaaa4TTIvcbHJsP8aUP723reUzyxvw9Bdo9wC2bzE1xlOhm/WCmF
|
||||||
|
0SNvEl6H/kivTjQkI2HQuGVq037eIAB5rToT6cVD3TiNmN6UuOX7Ec+8kw4JPGgLA/l+AB
|
||||||
|
w3gCsyK7MyZoeWNw2+b1utkjMcqG0bjju0yTdjSho6KazGtoBQ4P+Jx9KIwiJT13Nr1WMz
|
||||||
|
KBW98YojZCfCxPeNx6RPsp6PzM673R9DVRNXSs3yYhEZDXJEHCS7jDptR8r8uScogIIUEx
|
||||||
|
YShJU0/WSVHgHZ4Ef2S7MDX1RLU4WGoUtbwxnTEQ26iNLjskYzV9/O88PajJSc2Wcz5vES
|
||||||
|
I4BFROg2px+ViLlWqiegXIZc5NnN2HSJQ7ucTObSL0+oT5SzQiRfHy2TLa4w+c5hgO1VNx
|
||||||
|
Xmq0doKjMW9DmU2ygwzFgnaQp9S8NlIIA/4mKkAODbCgWFqXz99gMgfL+dnUhwo4WHN3lU
|
||||||
|
D/uVxRxwTKWWNp39z/p5hBYLKpqJbDCp+ysM9VpyllAkjk9aDihUq5dQVzpA1iTFH2DdbM
|
||||||
|
TrclBWaXr9QQiH+F73mZvJPhP2//gT9qped6XumkSpuNXFrXoZ/P49xKgQ/51rg8Ri5ZJ7
|
||||||
|
cIiofoppfat5ex20oBqAnumrM0JrhUrVxzhSd5tPPH5JGeZYml3sK1rM4pV7K7bnugXg9f
|
||||||
|
C6HVxe/l2klAOvg0U9yJAvR35mS0+F0dpwvjRrFS/+JxG6RzzAAunDJHjADNne5FhKFNLB
|
||||||
|
WRzsXHTCT+wGp497Nq8uS/0sgZAMHsy2KMK6n5h8V6kHL9t5VgsD18g0neu9ytwYrjvAuM
|
||||||
|
AoDdwpuUkCJVNOiMHumxPvivGRNhSHwW7fTDHX+yI6/j1i/Wl1unjCxNgNCbgCMRCg1+dN
|
||||||
|
wRw/wqs4mQyGf70AUA5JIVx/W7gAxlt3YWCFHfTRiK5A/BHa0qs+RPMzVlIJhAx0TGAOze
|
||||||
|
BBJIg2kH26rWLV2aosOx8FFH/rZVj6gyYLw0JlsoTCva383SkifvlfiLY3DxfU+bwvJ9p0
|
||||||
|
bnzyMMiKRuZb16OucNli84FIAuI=
|
||||||
|
-----END OPENSSH PRIVATE KEY-----
|
1
src-tauri/src/credentials/fixtures/ssh_rsa_enc.pub
Normal file
1
src-tauri/src/credentials/fixtures/ssh_rsa_enc.pub
Normal file
@ -0,0 +1 @@
|
|||||||
|
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCwIeD+SUIx511M/JztbfUkEi8OhnF3ELsGYjbRq/ABy0x8tYlk6ZjlOFg2kSMAEpoTNOVJp+9eDsQRXZfgmQOKz7ugrS/l1XT38/8SKpe1n3XYti3Nkh/xpbv9y1XXd3mkGr1GUorviywNqWNw/Ox5OH7tasHAztBBrpecu4gL7GwXkXcRFD8cTQNVSNJzdz0B4/ZDRjgRtzOdn0s/yNH+z3YciHjBNeLmANnSMPEaOtjgQV0akjo5jRCOkEP2MKm3uxMQknyopGRVCW4aJyula28G/uV2TjyKyF6l3kCK7Y7FSb50n7IxwaLMldoANdsAmp37LGIoM6VM/Muf+xcxWQZfMzXG3a+7Uqgs1cMnGYxJbCpEBPGlRNghdfkVlUSSpEiJVLN8eMH4G0Z6BflszCYUbN9RevcWdARpR7Ec6AsOJ3squiORWvEemp+dBXF+Hw7XA0H0cAhVafiNj1y7gXlgWgv4hTeleJMynRXx+Mo1gr42EkEPBlaFM/gBYC8= hello world
|
38
src-tauri/src/credentials/fixtures/ssh_rsa_plain
Normal file
38
src-tauri/src/credentials/fixtures/ssh_rsa_plain
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
|
||||||
|
NhAAAAAwEAAQAAAYEAxKvObWlACRLrrVJ3M0AeMOvz3JQzt5yOND16++GanzCZNIIld9mA
|
||||||
|
c0a0jU+wYE0CLagm5WJGNeTOGYUapdMN/SAH3pmwSuTC8Agj3/w8jd5i8HSDHB+JAwZ8g9
|
||||||
|
zNfZzuhkPJPFKR9rUEf1NkajfIQJiTT/3liCtd12ls3cRCHDniiUdoz9ZlDOWF41o/c5sB
|
||||||
|
VlCqRpq978aYflXa/sfUC0OIZU7TIF0YUo2IGSfELL4hDM9vSakGGa1uasvxdo1+xS/5y4
|
||||||
|
W+YHuUFJYVZikgFodRZMHB0fvUw1adRCSn8Z0EOrze5QVz38T8fywnGKp2Uo+iJsDdVTDc
|
||||||
|
cFwwkB4b3oj+XMNcrlq4gm0ef5cNvtCg9+mDPPxzI6HxMjUo1cw8AK65gWXWd8r2S9aXKR
|
||||||
|
MiZNlxtcSR0K6vU6rSLQN1ay5DdUUC6ESIEX7ruWLM3131loLB6bpHLVq5uD2yhi5+o4Do
|
||||||
|
/SDek2jKvLvFyVwjOlLeXf5ekctZAZ0AtSnHDEMFAAAFgMFqGjPBahozAAAAB3NzaC1yc2
|
||||||
|
EAAAGBAMSrzm1pQAkS661SdzNAHjDr89yUM7ecjjQ9evvhmp8wmTSCJXfZgHNGtI1PsGBN
|
||||||
|
Ai2oJuViRjXkzhmFGqXTDf0gB96ZsErkwvAII9/8PI3eYvB0gxwfiQMGfIPczX2c7oZDyT
|
||||||
|
xSkfa1BH9TZGo3yECYk0/95YgrXddpbN3EQhw54olHaM/WZQzlheNaP3ObAVZQqkaave/G
|
||||||
|
mH5V2v7H1AtDiGVO0yBdGFKNiBknxCy+IQzPb0mpBhmtbmrL8XaNfsUv+cuFvmB7lBSWFW
|
||||||
|
YpIBaHUWTBwdH71MNWnUQkp/GdBDq83uUFc9/E/H8sJxiqdlKPoibA3VUw3HBcMJAeG96I
|
||||||
|
/lzDXK5auIJtHn+XDb7QoPfpgzz8cyOh8TI1KNXMPACuuYFl1nfK9kvWlykTImTZcbXEkd
|
||||||
|
Cur1Oq0i0DdWsuQ3VFAuhEiBF+67lizN9d9ZaCwem6Ry1aubg9soYufqOA6P0g3pNoyry7
|
||||||
|
xclcIzpS3l3+XpHLWQGdALUpxwxDBQAAAAMBAAEAAAGABsfTnKMR0Z5E4Ntkf7BYuiAQbs
|
||||||
|
zvQYfUwUlTWabMEWv4BD7ucsTdcFwCMpMKRi+xgQh4mtT6DbafQnL72ba+lzkI/Gw5D0P2
|
||||||
|
0pa9QeYs4klGCPtDX+9YZnHNTjCJJykHcjqZEAravHI+PvONlTnqHgwEnC/pP3obSKd6WO
|
||||||
|
UA0H9QZ6I+I1hFcJ3jMVT1thMkhyjNzhRcsw0aSdTE8Z7LGT5RUAjZL5b2FTaK+C8OTOqb
|
||||||
|
MhlewV/h9XWsxmLUpt0277I8ShvjJbJg6TEPJh6D7FRTU+tY4rjGK12DP9lVq6M7Md4ULV
|
||||||
|
JW3aW350xVV2p9031HLDUfWs7dqZ5ufoD3EopOVZGvfGAE3C4aHvJB5D6K7wG7ptWsPgte
|
||||||
|
EcCz84DpsoJ7KICTs8QoXt5bl68qnW3YvzCcqZc7DjLdKNh/wzjdMdzx8AMS4yBF2ceOSE
|
||||||
|
I7Og9UZZtmGzZ0g4Dhg3jMUyWBA++sUayJUqg0izzA/htt+tVd9ABMkJOufcCpnuPRAAAA
|
||||||
|
wCdCy66KXCLx5HCMIsd2/TdbGAZnuirYCn9ee3T5xhJyZjmwIfmZEXUuENKq8e+vYldwey
|
||||||
|
EjdnevM+OCTc8xo77yowgYRBzguDa2R9UH3bg9cWZIpQGzXmnL35Dux4nZPUKs69WMht92
|
||||||
|
bpRh9roPs2M5tSAcSpmfohFYhMwRxqVooSeSg+kGE4dCXnVqK1tURExnqKy8CkoDW4fhbH
|
||||||
|
HNmPsBnbdTNtfAlg8MO1v1Hk+/+6mpNhiJ7bKF4au9lm+QHgAAAMEA11frEHqordrzlTRg
|
||||||
|
kmqGq9qaORev2g/7n719DlXb2HjGfy5gK9iUCxsgGN6GiFF0mUD7hMY6UMIVfsC/Rm07aE
|
||||||
|
700u7OJAm8AcnFkEANlZ3ucWltnumVtxyMBlKq7PxkcIG5X+nJ6N8oVw3zZTsjaYCMe1s1
|
||||||
|
806oE5D3GZk10pnfVIrY9DFZBtT3+mBpF2uQZk0ZSwh8Hh9xGFGxsm6blkgpcip7v+26PR
|
||||||
|
hqA88WlXAPMnvFpXthr0mny+cy7Q59AAAAwQDpzWi1Prhi3JtVolyac/ygvzje4lhuz5ei
|
||||||
|
3pC7b1cepdFoQCS33tixwfzqKCp6RfHrtrKzZMqREaX5sor1Hha7S+Vo+KLtZWkFUONTHR
|
||||||
|
987wmXIu8ziRWKBeuk6g9OSXI5w8hyLwn4XLEeVri4fAUIUwpi4B0Eazp4P/9AUf1188xz
|
||||||
|
a4ACWXDYkUFoLQo9J07HWDhKbEKFZVlIznyfmLVXc8JEzwrPThW+viGK1AFi9FxeLB4QmK
|
||||||
|
PkAC2GY5AmhSkAAAALaGVsbG8gd29ybGQ=
|
||||||
|
-----END OPENSSH PRIVATE KEY-----
|
1
src-tauri/src/credentials/fixtures/ssh_rsa_plain.pub
Normal file
1
src-tauri/src/credentials/fixtures/ssh_rsa_plain.pub
Normal file
@ -0,0 +1 @@
|
|||||||
|
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDEq85taUAJEuutUnczQB4w6/PclDO3nI40PXr74ZqfMJk0giV32YBzRrSNT7BgTQItqCblYkY15M4ZhRql0w39IAfembBK5MLwCCPf/DyN3mLwdIMcH4kDBnyD3M19nO6GQ8k8UpH2tQR/U2RqN8hAmJNP/eWIK13XaWzdxEIcOeKJR2jP1mUM5YXjWj9zmwFWUKpGmr3vxph+Vdr+x9QLQ4hlTtMgXRhSjYgZJ8QsviEMz29JqQYZrW5qy/F2jX7FL/nLhb5ge5QUlhVmKSAWh1FkwcHR+9TDVp1EJKfxnQQ6vN7lBXPfxPx/LCcYqnZSj6ImwN1VMNxwXDCQHhveiP5cw1yuWriCbR5/lw2+0KD36YM8/HMjofEyNSjVzDwArrmBZdZ3yvZL1pcpEyJk2XG1xJHQrq9TqtItA3VrLkN1RQLoRIgRfuu5YszfXfWWgsHpukctWrm4PbKGLn6jgOj9IN6TaMq8u8XJXCM6Ut5d/l6Ry1kBnQC1KccMQwU= hello world
|
120
src-tauri/src/credentials/mod.rs
Normal file
120
src-tauri/src/credentials/mod.rs
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use sqlx::{
|
||||||
|
FromRow,
|
||||||
|
Sqlite,
|
||||||
|
SqlitePool,
|
||||||
|
sqlite::SqliteRow,
|
||||||
|
Transaction,
|
||||||
|
types::Uuid,
|
||||||
|
};
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
use crate::errors::*;
|
||||||
|
|
||||||
|
mod aws;
|
||||||
|
pub use aws::{AwsBaseCredential, AwsSessionCredential};
|
||||||
|
|
||||||
|
mod crypto;
|
||||||
|
pub use crypto::Crypto;
|
||||||
|
|
||||||
|
mod record;
|
||||||
|
pub use record::CredentialRecord;
|
||||||
|
|
||||||
|
mod session;
|
||||||
|
pub use session::AppSession;
|
||||||
|
|
||||||
|
mod ssh;
|
||||||
|
pub use ssh::SshKey;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum Credential {
|
||||||
|
AwsBase(AwsBaseCredential),
|
||||||
|
AwsSession(AwsSessionCredential),
|
||||||
|
Ssh(SshKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized {
|
||||||
|
type Row: Send + Unpin + for<'r> FromRow<'r, SqliteRow>;
|
||||||
|
|
||||||
|
fn type_name() -> &'static str;
|
||||||
|
|
||||||
|
fn into_credential(self) -> Credential;
|
||||||
|
|
||||||
|
fn row_id(row: &Self::Row) -> Uuid;
|
||||||
|
|
||||||
|
fn from_row(row: Self::Row, crypto: &Crypto) -> Result<Self, LoadCredentialsError>;
|
||||||
|
|
||||||
|
// save_details needs to be implemented per-type because we don't know the number of parameters in advance
|
||||||
|
async fn save_details(&self, id: &Uuid, crypto: &Crypto, txn: &mut Transaction<'_, Sqlite>) -> Result<(), SaveCredentialsError>;
|
||||||
|
|
||||||
|
fn table_name() -> String {
|
||||||
|
format!("{}_credentials", Self::type_name())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load(id: &Uuid, crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
|
||||||
|
let q = format!("SELECT * FROM {} WHERE id = ?", Self::table_name());
|
||||||
|
let row: Self::Row = sqlx::query_as(&q)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(LoadCredentialsError::NoCredentials)?;
|
||||||
|
|
||||||
|
Self::from_row(row, crypto)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_by_name(name: &str, crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
|
||||||
|
let q = format!(
|
||||||
|
"SELECT * FROM {} WHERE id = (SELECT id FROM credentials WHERE name = ?)",
|
||||||
|
Self::table_name(),
|
||||||
|
);
|
||||||
|
let row: Self::Row = sqlx::query_as(&q)
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(LoadCredentialsError::NoCredentials)?;
|
||||||
|
|
||||||
|
Self::from_row(row, crypto)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_default(crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
|
||||||
|
let q = format!(
|
||||||
|
"SELECT details.*
|
||||||
|
FROM {} details
|
||||||
|
JOIN credentials c
|
||||||
|
ON c.id = details.id
|
||||||
|
AND c.is_default = 1",
|
||||||
|
Self::table_name(),
|
||||||
|
);
|
||||||
|
let row: Self::Row = sqlx::query_as(&q)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(LoadCredentialsError::NoCredentials)?;
|
||||||
|
|
||||||
|
Self::from_row(row, crypto)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(crypto: &Crypto, pool: &SqlitePool) -> Result<Vec<(Uuid, Credential)>, LoadCredentialsError> {
|
||||||
|
let q = format!(
|
||||||
|
"SELECT details.*
|
||||||
|
FROM
|
||||||
|
{} details
|
||||||
|
JOIN credentials c
|
||||||
|
ON c.id = details.id
|
||||||
|
ORDER BY c.created_at",
|
||||||
|
Self::table_name(),
|
||||||
|
);
|
||||||
|
let mut rows = sqlx::query_as::<_, Self::Row>(&q).fetch(pool);
|
||||||
|
|
||||||
|
let mut creds = Vec::new();
|
||||||
|
while let Some(row) = rows.try_next().await? {
|
||||||
|
let id = Self::row_id(&row);
|
||||||
|
let cred = Self::from_row(row, crypto)?.into_credential();
|
||||||
|
creds.push((id, cred));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(creds)
|
||||||
|
}
|
||||||
|
}
|
430
src-tauri/src/credentials/record.rs
Normal file
430
src-tauri/src/credentials/record.rs
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt::{self, Debug, Formatter};
|
||||||
|
use serde::{
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
Serializer,
|
||||||
|
Deserializer,
|
||||||
|
};
|
||||||
|
use serde::de::{self, Visitor};
|
||||||
|
use sqlx::{
|
||||||
|
Error as SqlxError,
|
||||||
|
FromRow,
|
||||||
|
SqlitePool,
|
||||||
|
types::Uuid,
|
||||||
|
};
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
use crate::errors::*;
|
||||||
|
use super::{
|
||||||
|
AwsBaseCredential,
|
||||||
|
Credential,
|
||||||
|
Crypto,
|
||||||
|
PersistentCredential,
|
||||||
|
SshKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct CredentialRow {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
credential_type: String,
|
||||||
|
is_default: bool,
|
||||||
|
created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CredentialRecord {
|
||||||
|
#[serde(serialize_with = "serialize_uuid")]
|
||||||
|
#[serde(deserialize_with = "deserialize_uuid")]
|
||||||
|
pub id: Uuid, // UUID so it can be generated on the frontend
|
||||||
|
pub name: String, // user-facing identifier so it can be changed
|
||||||
|
pub is_default: bool,
|
||||||
|
pub credential: Credential,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredentialRecord {
|
||||||
|
pub async fn save(&self, crypto: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
|
||||||
|
let type_name = match &self.credential {
|
||||||
|
Credential::AwsBase(_) => AwsBaseCredential::type_name(),
|
||||||
|
Credential::Ssh(_) => SshKey::type_name(),
|
||||||
|
_ => return Err(SaveCredentialsError::NotPersistent),
|
||||||
|
};
|
||||||
|
|
||||||
|
// if the credential being saved is default, make sure it's the only default of its type
|
||||||
|
let mut txn = pool.begin().await?;
|
||||||
|
if self.is_default {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE credentials SET is_default = 0 WHERE credential_type = ?",
|
||||||
|
type_name
|
||||||
|
).execute(&mut *txn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// save to parent credentials table
|
||||||
|
let res = sqlx::query!(
|
||||||
|
"INSERT INTO credentials (id, name, credential_type, is_default, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, strftime('%s'))
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
credential_type = excluded.credential_type,
|
||||||
|
is_default = excluded.is_default",
|
||||||
|
self.id, self.name, type_name, self.is_default
|
||||||
|
).execute(&mut *txn).await;
|
||||||
|
|
||||||
|
// if id is unique, but name is not, we will get an error
|
||||||
|
// (if id is not unique, this becomes an upsert due to ON CONFLICT clause)
|
||||||
|
match res {
|
||||||
|
Err(SqlxError::Database(e)) if e.is_unique_violation() => Err(SaveCredentialsError::Duplicate),
|
||||||
|
Err(e) => Err(SaveCredentialsError::DbError(e)),
|
||||||
|
Ok(_) => Ok(())
|
||||||
|
}?;
|
||||||
|
|
||||||
|
// save credential details to child table
|
||||||
|
match &self.credential {
|
||||||
|
Credential::AwsBase(b) => b.save_details(&self.id, crypto, &mut txn).await,
|
||||||
|
Credential::Ssh(s) => s.save_details(&self.id, crypto, &mut txn).await,
|
||||||
|
_ => Err(SaveCredentialsError::NotPersistent),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
// make it real
|
||||||
|
txn.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_parts(row: CredentialRow, credential: Credential) -> Self {
|
||||||
|
CredentialRecord {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
is_default: row.is_default,
|
||||||
|
credential,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_credential(row: CredentialRow, crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
|
||||||
|
let credential = match row.credential_type.as_str() {
|
||||||
|
"aws" => Credential::AwsBase(AwsBaseCredential::load(&row.id, crypto, pool).await?),
|
||||||
|
_ => return Err(LoadCredentialsError::InvalidData),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self::from_parts(row, credential))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub async fn load(id: &Uuid, crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
|
||||||
|
let row: CredentialRow = sqlx::query_as("SELECT * FROM credentials WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(LoadCredentialsError::NoCredentials)?;
|
||||||
|
|
||||||
|
Self::load_credential(row, crypto, pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_by_name(name: &str, crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
|
||||||
|
let row: CredentialRow = sqlx::query_as("SELECT * FROM credentials WHERE name = ?")
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(LoadCredentialsError::NoCredentials)?;
|
||||||
|
|
||||||
|
Self::load_credential(row, crypto, pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_default(credential_type: &str, crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
|
||||||
|
let row: CredentialRow = sqlx::query_as(
|
||||||
|
"SELECT * FROM credentials
|
||||||
|
WHERE credential_type = ? AND is_default = 1"
|
||||||
|
).bind(credential_type)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(LoadCredentialsError::NoCredentials)?;
|
||||||
|
|
||||||
|
Self::load_credential(row, crypto, pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(crypto: &Crypto, pool: &SqlitePool) -> Result<Vec<Self>, LoadCredentialsError> {
|
||||||
|
let mut parent_rows = sqlx::query_as::<_, CredentialRow>(
|
||||||
|
"SELECT * FROM credentials"
|
||||||
|
).fetch(pool);
|
||||||
|
|
||||||
|
let mut parent_map = HashMap::new();
|
||||||
|
while let Some(row) = parent_rows.try_next().await? {
|
||||||
|
parent_map.insert(row.id, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut records = Vec::with_capacity(parent_map.len());
|
||||||
|
|
||||||
|
for (id, credential) in AwsBaseCredential::list(crypto, pool).await? {
|
||||||
|
let parent = parent_map.remove(&id)
|
||||||
|
.ok_or(LoadCredentialsError::InvalidData)?;
|
||||||
|
records.push(Self::from_parts(parent, credential));
|
||||||
|
}
|
||||||
|
for (id, credential) in SshKey::list(crypto, pool).await? {
|
||||||
|
let parent = parent_map.remove(&id)
|
||||||
|
.ok_or(LoadCredentialsError::InvalidData)?;
|
||||||
|
records.push(Self::from_parts(parent, credential));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rekey(old: &Crypto, new: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
|
||||||
|
for record in Self::list(old, pool).await? {
|
||||||
|
record.save(new, pool).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn serialize_uuid<S: Serializer>(u: &Uuid, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let mut buf = Uuid::encode_buffer();
|
||||||
|
s.serialize_str(u.as_hyphenated().encode_lower(&mut buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UuidVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for UuidVisitor {
|
||||||
|
type Value = Uuid;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
|
||||||
|
write!(formatter, "a hyphenated UUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: de::Error>(self, v: &str) -> Result<Uuid, E> {
|
||||||
|
Uuid::try_parse(v)
|
||||||
|
.map_err(|_| E::custom(format!("Could not interpret string as UUID: {v}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_uuid<'de, D: Deserializer<'de>>(ds: D) -> Result<Uuid, D::Error> {
|
||||||
|
ds.deserialize_str(UuidVisitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use sqlx::types::uuid::uuid;
|
||||||
|
|
||||||
|
|
||||||
|
fn aws_record() -> CredentialRecord {
|
||||||
|
let id = uuid!("00000000-0000-0000-0000-000000000000");
|
||||||
|
let aws = AwsBaseCredential::new(
|
||||||
|
"AKIAIOSFODNN7EXAMPLE".into(),
|
||||||
|
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
CredentialRecord {
|
||||||
|
id,
|
||||||
|
name: "test".into(),
|
||||||
|
is_default: true,
|
||||||
|
credential: Credential::AwsBase(aws),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aws_record_2() -> CredentialRecord {
|
||||||
|
let id = uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
||||||
|
let aws = AwsBaseCredential::new(
|
||||||
|
"AKIAIOSFODNN7EXAMPL2".into(),
|
||||||
|
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKE2".into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
CredentialRecord {
|
||||||
|
id,
|
||||||
|
name: "test2".into(),
|
||||||
|
is_default: false,
|
||||||
|
credential: Credential::AwsBase(aws),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_uuid() -> Uuid {
|
||||||
|
let bytes = Crypto::salt();
|
||||||
|
Uuid::from_slice(&bytes[..16]).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_load_aws(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::fixed();
|
||||||
|
let id = uuid!("00000000-0000-0000-0000-000000000000");
|
||||||
|
let loaded = CredentialRecord::load(&id, &crypt, &pool).await
|
||||||
|
.expect("Failed to load record");
|
||||||
|
|
||||||
|
assert_eq!(aws_record(), loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_load_aws_default(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::fixed();
|
||||||
|
let loaded = CredentialRecord::load_default("aws", &crypt, &pool).await
|
||||||
|
.expect("Failed to load record");
|
||||||
|
|
||||||
|
assert_eq!(aws_record(), loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_save_aws(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::random();
|
||||||
|
let mut record = aws_record();
|
||||||
|
record.id = random_uuid();
|
||||||
|
|
||||||
|
aws_record().save(&crypt, &pool).await
|
||||||
|
.expect("Failed to save record");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_save_load_aws(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::random();
|
||||||
|
let mut record = aws_record();
|
||||||
|
record.id = random_uuid();
|
||||||
|
|
||||||
|
record.save(&crypt, &pool).await
|
||||||
|
.expect("Failed to save record");
|
||||||
|
let loaded = CredentialRecord::load(&record.id, &crypt, &pool).await
|
||||||
|
.expect("Failed to load record");
|
||||||
|
|
||||||
|
assert_eq!(record, loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_overwrite_aws(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::fixed();
|
||||||
|
|
||||||
|
let original = aws_record();
|
||||||
|
original.save(&crypt, &pool).await
|
||||||
|
.expect("Failed to save first record");
|
||||||
|
|
||||||
|
let mut updated = aws_record_2();
|
||||||
|
updated.id = original.id;
|
||||||
|
updated.save(&crypt, &pool).await
|
||||||
|
.expect("Failed to overwrite first record with second record");
|
||||||
|
|
||||||
|
// make sure update went through
|
||||||
|
let loaded = CredentialRecord::load(&updated.id, &crypt, &pool).await.unwrap();
|
||||||
|
assert_eq!(updated, loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_duplicate_name(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::random();
|
||||||
|
|
||||||
|
let mut record = aws_record();
|
||||||
|
record.id = random_uuid();
|
||||||
|
let resp = record.save(&crypt, &pool).await;
|
||||||
|
|
||||||
|
if !matches!(resp, Err(SaveCredentialsError::Duplicate)) {
|
||||||
|
panic!("Attempt to create duplicate entry returned {resp:?}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_change_default(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::fixed();
|
||||||
|
let id = uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
||||||
|
|
||||||
|
// confirm that record as it currently exists in the database is not default
|
||||||
|
let mut record = CredentialRecord::load(&id, &crypt, &pool).await
|
||||||
|
.expect("Failed to load record");
|
||||||
|
assert!(!record.is_default);
|
||||||
|
|
||||||
|
record.is_default = true;
|
||||||
|
record.save(&crypt, &pool).await
|
||||||
|
.expect("Failed to save record");
|
||||||
|
|
||||||
|
let loaded = CredentialRecord::load(&id, &crypt, &pool).await
|
||||||
|
.expect("Failed to re-load record");
|
||||||
|
assert!(loaded.is_default);
|
||||||
|
|
||||||
|
let other_id = uuid!("00000000-0000-0000-0000-000000000000");
|
||||||
|
let other_loaded = CredentialRecord::load(&other_id, &crypt, &pool).await
|
||||||
|
.expect("Failed to load other credential");
|
||||||
|
assert!(!other_loaded.is_default);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_list(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::fixed();
|
||||||
|
|
||||||
|
let records = CredentialRecord::list(&crypt, &pool).await
|
||||||
|
.expect("Failed to list credentials");
|
||||||
|
|
||||||
|
assert_eq!(aws_record(), records[0]);
|
||||||
|
assert_eq!(aws_record_2(), records[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_rekey(pool: SqlitePool) {
|
||||||
|
let old = Crypto::fixed();
|
||||||
|
let new = Crypto::random();
|
||||||
|
|
||||||
|
CredentialRecord::rekey(&old, &new, &pool).await
|
||||||
|
.expect("Failed to rekey credentials");
|
||||||
|
|
||||||
|
let records = CredentialRecord::list(&new, &pool).await
|
||||||
|
.expect("Failed to re-list credentials");
|
||||||
|
|
||||||
|
assert_eq!(aws_record(), records[0]);
|
||||||
|
assert_eq!(aws_record_2(), records[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod uuid_tests {
|
||||||
|
use super::*;
|
||||||
|
use sqlx::types::uuid::uuid;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
struct UuidWrapper {
|
||||||
|
#[serde(serialize_with = "serialize_uuid")]
|
||||||
|
#[serde(deserialize_with = "deserialize_uuid")]
|
||||||
|
id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_uuid() {
|
||||||
|
let u = UuidWrapper {
|
||||||
|
id: uuid!("693f84d2-4c1b-41e5-8483-cbe178324e04")
|
||||||
|
};
|
||||||
|
let computed = serde_json::to_string(&u).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
"{\"id\":\"693f84d2-4c1b-41e5-8483-cbe178324e04\"}",
|
||||||
|
&computed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_uuid() {
|
||||||
|
let s = "{\"id\":\"045bd359-8630-4b76-9b7d-e4a86ed2222c\"}";
|
||||||
|
let computed = serde_json::from_str(s).unwrap();
|
||||||
|
let expected = UuidWrapper {
|
||||||
|
id: uuid!("045bd359-8630-4b76-9b7d-e4a86ed2222c"),
|
||||||
|
};
|
||||||
|
assert_eq!(expected, computed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_deserialize_uuid() {
|
||||||
|
let buf = Crypto::salt();
|
||||||
|
let expected = UuidWrapper{
|
||||||
|
id: Uuid::from_slice(&buf[..16]).unwrap()
|
||||||
|
};
|
||||||
|
let serialized = serde_json::to_string(&expected).unwrap();
|
||||||
|
let computed = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(expected, computed)
|
||||||
|
}
|
||||||
|
}
|
100
src-tauri/src/credentials/session.rs
Normal file
100
src-tauri/src/credentials/session.rs
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
use chacha20poly1305::XNonce;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::errors::*;
|
||||||
|
use crate::kv;
|
||||||
|
use super::Crypto;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum AppSession {
|
||||||
|
Unlocked {
|
||||||
|
salt: [u8; 32],
|
||||||
|
crypto: Crypto,
|
||||||
|
},
|
||||||
|
Locked {
|
||||||
|
salt: [u8; 32],
|
||||||
|
verify_nonce: XNonce,
|
||||||
|
verify_blob: Vec<u8>
|
||||||
|
},
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppSession {
|
||||||
|
pub fn new(passphrase: &str) -> Result<Self, CryptoError> {
|
||||||
|
let salt = Crypto::salt();
|
||||||
|
let crypto = Crypto::new(passphrase, &salt)?;
|
||||||
|
Ok(Self::Unlocked {salt, crypto})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unlock(&mut self, passphrase: &str) -> Result<(), UnlockError> {
|
||||||
|
let (salt, nonce, blob) = match self {
|
||||||
|
Self::Empty => return Err(UnlockError::NoCredentials),
|
||||||
|
Self::Unlocked {..} => return Err(UnlockError::NotLocked),
|
||||||
|
Self::Locked {salt, verify_nonce, verify_blob} => (salt, verify_nonce, verify_blob),
|
||||||
|
};
|
||||||
|
|
||||||
|
let crypto = Crypto::new(passphrase, salt)
|
||||||
|
.map_err(|e| CryptoError::Argon2(e))?;
|
||||||
|
|
||||||
|
// if passphrase is incorrect, this will fail
|
||||||
|
let _verify = crypto.decrypt(&nonce, &blob)?;
|
||||||
|
|
||||||
|
*self = Self::Unlocked {crypto, salt: *salt};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load(pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
|
||||||
|
match kv::load_bytes_multi!(pool, "salt", "verify_nonce", "verify_blob").await? {
|
||||||
|
Some((salt, nonce, blob)) => {
|
||||||
|
|
||||||
|
Ok(Self::Locked {
|
||||||
|
salt: salt.try_into().map_err(|_| LoadCredentialsError::InvalidData)?,
|
||||||
|
// note: replace this with try_from at some point
|
||||||
|
verify_nonce: XNonce::clone_from_slice(&nonce),
|
||||||
|
verify_blob: blob,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
None => Ok(Self::Empty),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(&self, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
|
||||||
|
match self {
|
||||||
|
Self::Unlocked {salt, crypto} => {
|
||||||
|
let (nonce, blob) = crypto.encrypt(b"correct horse battery staple")?;
|
||||||
|
kv::save_bytes(pool, "salt", salt).await?;
|
||||||
|
kv::save_bytes(pool, "verify_nonce", &nonce.as_slice()).await?;
|
||||||
|
kv::save_bytes(pool, "verify_blob", &blob).await?;
|
||||||
|
},
|
||||||
|
Self::Locked {salt, verify_nonce, verify_blob} => {
|
||||||
|
kv::save_bytes(pool, "salt", salt).await?;
|
||||||
|
kv::save_bytes(pool, "verify_nonce", &verify_nonce.as_slice()).await?;
|
||||||
|
kv::save_bytes(pool, "verify_blob", verify_blob).await?;
|
||||||
|
},
|
||||||
|
// "saving" an empty session just means doing nothing
|
||||||
|
Self::Empty => (),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reset(&mut self, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
|
||||||
|
match self {
|
||||||
|
Self::Unlocked {..} | Self::Locked {..} => {
|
||||||
|
kv::delete_multi(pool, &["salt", "verify_nonce", "verify_blob"]).await?;
|
||||||
|
*self = Self::Empty;
|
||||||
|
},
|
||||||
|
Self::Empty => (),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_get_crypto(&self) -> Result<&Crypto, GetCredentialsError> {
|
||||||
|
match self {
|
||||||
|
Self::Empty => Err(GetCredentialsError::Empty),
|
||||||
|
Self::Locked {..} => Err(GetCredentialsError::Locked),
|
||||||
|
Self::Unlocked {crypto, ..} => Ok(crypto),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
481
src-tauri/src/credentials/ssh.rs
Normal file
481
src-tauri/src/credentials/ssh.rs
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
use std::fmt::{self, Formatter};
|
||||||
|
|
||||||
|
use chacha20poly1305::XNonce;
|
||||||
|
use serde::{
|
||||||
|
Deserialize,
|
||||||
|
Deserializer,
|
||||||
|
Serialize,
|
||||||
|
Serializer,
|
||||||
|
};
|
||||||
|
use serde::ser::{
|
||||||
|
Error as SerError,
|
||||||
|
SerializeStruct,
|
||||||
|
};
|
||||||
|
use serde::de::{self, Visitor};
|
||||||
|
use sha2::{Sha256, Sha512};
|
||||||
|
use signature::{Signer, SignatureEncoding};
|
||||||
|
use sqlx::{
|
||||||
|
FromRow,
|
||||||
|
Sqlite,
|
||||||
|
SqlitePool,
|
||||||
|
Transaction,
|
||||||
|
types::Uuid,
|
||||||
|
};
|
||||||
|
use ssh_agent_lib::proto::message::{
|
||||||
|
Identity,
|
||||||
|
SignRequest,
|
||||||
|
};
|
||||||
|
use ssh_encoding::Encode;
|
||||||
|
use ssh_key::{
|
||||||
|
Algorithm,
|
||||||
|
LineEnding,
|
||||||
|
private::{PrivateKey, KeypairData},
|
||||||
|
public::PublicKey,
|
||||||
|
};
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
use crate::errors::*;
|
||||||
|
use super::{
|
||||||
|
Credential,
|
||||||
|
Crypto,
|
||||||
|
PersistentCredential,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow)]
|
||||||
|
pub struct SshRow {
|
||||||
|
id: Uuid,
|
||||||
|
algorithm: String,
|
||||||
|
comment: String,
|
||||||
|
public_key: Vec<u8>,
|
||||||
|
private_key_enc: Vec<u8>,
|
||||||
|
nonce: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Deserialize)]
|
||||||
|
pub struct SshKey {
|
||||||
|
#[serde(deserialize_with = "deserialize_algorithm")]
|
||||||
|
pub algorithm: Algorithm,
|
||||||
|
pub comment: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_pubkey")]
|
||||||
|
pub public_key: PublicKey,
|
||||||
|
#[serde(deserialize_with = "deserialize_privkey")]
|
||||||
|
pub private_key: PrivateKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshKey {
|
||||||
|
pub fn from_file(path: &str, passphrase: &str) -> Result<SshKey, LoadSshKeyError> {
|
||||||
|
let mut privkey = PrivateKey::read_openssh_file(path.as_ref())?;
|
||||||
|
if privkey.is_encrypted() {
|
||||||
|
privkey = privkey.decrypt(passphrase)
|
||||||
|
.map_err(|_| LoadSshKeyError::InvalidPassphrase)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SshKey {
|
||||||
|
algorithm: privkey.algorithm(),
|
||||||
|
comment: privkey.comment().into(),
|
||||||
|
public_key: privkey.public_key().clone(),
|
||||||
|
private_key: privkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_private_key(private_key: &str, passphrase: &str) -> Result<SshKey, LoadSshKeyError> {
|
||||||
|
let mut privkey = PrivateKey::from_openssh(private_key)?;
|
||||||
|
if privkey.is_encrypted() {
|
||||||
|
privkey = privkey.decrypt(passphrase)
|
||||||
|
.map_err(|_| LoadSshKeyError::InvalidPassphrase)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SshKey {
|
||||||
|
algorithm: privkey.algorithm(),
|
||||||
|
comment: privkey.comment().into(),
|
||||||
|
public_key: privkey.public_key().clone(),
|
||||||
|
private_key: privkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn name_from_pubkey(pubkey: &[u8], pool: &SqlitePool) -> Result<String, LoadCredentialsError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT c.name
|
||||||
|
FROM credentials c
|
||||||
|
JOIN ssh_credentials s
|
||||||
|
ON s.id = c.id
|
||||||
|
WHERE s.public_key = ?",
|
||||||
|
pubkey
|
||||||
|
).fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(LoadCredentialsError::NoCredentials)?;
|
||||||
|
|
||||||
|
Ok(row.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_identities(pool: &SqlitePool) -> Result<Vec<Identity>, LoadCredentialsError> {
|
||||||
|
let mut rows = sqlx::query!(
|
||||||
|
"SELECT public_key, comment FROM ssh_credentials"
|
||||||
|
).fetch(pool);
|
||||||
|
|
||||||
|
let mut identities = Vec::new();
|
||||||
|
while let Some(row) = rows.try_next().await? {
|
||||||
|
identities.push(Identity {
|
||||||
|
pubkey_blob: row.public_key,
|
||||||
|
comment: row.comment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(identities)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign_request(&self, req: &SignRequest) -> Result<Vec<u8>, HandlerError> {
|
||||||
|
let mut sig = Vec::new();
|
||||||
|
match self.private_key.key_data() {
|
||||||
|
KeypairData::Rsa(keypair) => {
|
||||||
|
// 2 is the flag value for `SSH_AGENT_RSA_SHA2_256`
|
||||||
|
if req.flags & 2 > 0 {
|
||||||
|
let signer = rsa::pkcs1v15::SigningKey::<Sha256>::try_from(keypair)?;
|
||||||
|
let sig_data = signer.try_sign(&req.data)?.to_vec();
|
||||||
|
"rsa-sha-256".encode(&mut sig)?;
|
||||||
|
sig_data.encode(&mut sig)?;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let signer = rsa::pkcs1v15::SigningKey::<Sha512>::try_from(keypair)?;
|
||||||
|
let sig_data = signer.try_sign(&req.data)?.to_vec();
|
||||||
|
"rsa-sha2-512".encode(&mut sig)?;
|
||||||
|
sig_data.encode(&mut sig)?;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
let sig_data = self.private_key.try_sign(&req.data)?;
|
||||||
|
self.algorithm.as_str().encode(&mut sig)?;
|
||||||
|
sig_data.as_bytes().encode(&mut sig)?;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Ok(sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl PersistentCredential for SshKey {
|
||||||
|
type Row = SshRow;
|
||||||
|
|
||||||
|
fn type_name() -> &'static str { "ssh" }
|
||||||
|
|
||||||
|
fn into_credential(self) -> Credential { Credential::Ssh(self) }
|
||||||
|
|
||||||
|
fn row_id(row: &SshRow) -> Uuid { row.id }
|
||||||
|
|
||||||
|
fn from_row(row: SshRow, crypto: &Crypto) -> Result<Self, LoadCredentialsError> {
|
||||||
|
let nonce = XNonce::clone_from_slice(&row.nonce);
|
||||||
|
let privkey_bytes = crypto.decrypt(&nonce, &row.private_key_enc)?;
|
||||||
|
|
||||||
|
|
||||||
|
let algorithm = Algorithm::new(&row.algorithm)
|
||||||
|
.map_err(|_| LoadCredentialsError::InvalidData)?;
|
||||||
|
let public_key = PublicKey::from_bytes(&row.public_key)
|
||||||
|
.map_err(|_| LoadCredentialsError::InvalidData)?;
|
||||||
|
let private_key = PrivateKey::from_bytes(&privkey_bytes)
|
||||||
|
.map_err(|_| LoadCredentialsError::InvalidData)?;
|
||||||
|
|
||||||
|
Ok(SshKey {
|
||||||
|
algorithm,
|
||||||
|
comment: row.comment,
|
||||||
|
public_key,
|
||||||
|
private_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_details(&self, id: &Uuid, crypto: &Crypto, txn: &mut Transaction<'_, Sqlite>) -> Result<(), SaveCredentialsError> {
|
||||||
|
let alg = self.algorithm.as_str();
|
||||||
|
let pubkey_bytes = self.public_key.to_bytes()?;
|
||||||
|
let privkey_bytes = self.private_key.to_bytes()?;
|
||||||
|
let (nonce, ciphertext) = crypto.encrypt(privkey_bytes.as_ref())?;
|
||||||
|
let nonce_bytes = nonce.as_slice();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT OR REPLACE INTO ssh_credentials (
|
||||||
|
id,
|
||||||
|
algorithm,
|
||||||
|
comment,
|
||||||
|
public_key,
|
||||||
|
private_key_enc,
|
||||||
|
nonce
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
id, alg, self.comment, pubkey_bytes, ciphertext, nonce_bytes,
|
||||||
|
).execute(&mut **txn).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl Serialize for SshKey {
|
||||||
|
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let mut key = s.serialize_struct("SshKey", 5)?;
|
||||||
|
key.serialize_field("algorithm", self.algorithm.as_str())?;
|
||||||
|
key.serialize_field("comment", &self.comment)?;
|
||||||
|
|
||||||
|
let pubkey_str = self.public_key.to_openssh()
|
||||||
|
.map_err(|e| S::Error::custom(format!("Failed to encode SSH public key: {e}")))?;
|
||||||
|
key.serialize_field("public_key", &pubkey_str)?;
|
||||||
|
|
||||||
|
let privkey_str = self.private_key.to_openssh(LineEnding::LF)
|
||||||
|
.map_err(|e| S::Error::custom(format!("Failed to encode SSH private key: {e}")))?;
|
||||||
|
key.serialize_field::<str>("private_key", privkey_str.as_ref())?;
|
||||||
|
|
||||||
|
key.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct PubkeyVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for PubkeyVisitor {
|
||||||
|
type Value = PublicKey;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
|
||||||
|
write!(formatter, "an OpenSSH-encoded public key, e.g. `ssh-rsa ...`")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||||
|
PublicKey::from_openssh(v)
|
||||||
|
.map_err(|e| E::custom(format!("{e}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_pubkey<'de, D>(deserializer: D) -> Result<PublicKey, D::Error>
|
||||||
|
where D: Deserializer<'de>
|
||||||
|
{
|
||||||
|
deserializer.deserialize_str(PubkeyVisitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct PrivkeyVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for PrivkeyVisitor {
|
||||||
|
type Value = PrivateKey;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
|
||||||
|
write!(formatter, "an OpenSSH-encoded private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||||
|
PrivateKey::from_openssh(v)
|
||||||
|
.map_err(|e| E::custom(format!("{e}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_privkey<'de, D>(deserializer: D) -> Result<PrivateKey, D::Error>
|
||||||
|
where D: Deserializer<'de>
|
||||||
|
{
|
||||||
|
deserializer.deserialize_str(PrivkeyVisitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct AlgorithmVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for AlgorithmVisitor {
|
||||||
|
type Value = Algorithm;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
|
||||||
|
write!(formatter, "an SSH key algorithm identifier, e.g. `ssh-rsa`")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||||
|
Algorithm::new(v)
|
||||||
|
.map_err(|e| E::custom(format!("{e}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_algorithm<'de, D>(deserializer: D) -> Result<Algorithm, D::Error>
|
||||||
|
where D: Deserializer<'de>
|
||||||
|
{
|
||||||
|
deserializer.deserialize_str(AlgorithmVisitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use sqlx::types::uuid::uuid;
|
||||||
|
use crate::credentials::CredentialRecord;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn path(name: &str) -> String {
|
||||||
|
format!("./src/credentials/fixtures/{name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_uuid() -> Uuid {
|
||||||
|
let bytes = Crypto::salt();
|
||||||
|
Uuid::from_slice(&bytes[..16]).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rsa_plain() -> SshKey {
|
||||||
|
SshKey::from_file(&path("ssh_rsa_plain"), "")
|
||||||
|
.expect("Failed to load SSH key")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rsa_enc() -> SshKey {
|
||||||
|
SshKey::from_file(
|
||||||
|
&path("ssh_rsa_enc"),
|
||||||
|
"correct horse battery staple"
|
||||||
|
).expect("Failed to load SSH key")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ed25519_plain() -> SshKey {
|
||||||
|
SshKey::from_file(&path("ssh_ed25519_plain"), "")
|
||||||
|
.expect("Failed to load SSH key")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ed25519_enc() -> SshKey {
|
||||||
|
SshKey::from_file(
|
||||||
|
&path("ssh_ed25519_enc"),
|
||||||
|
"correct horse battery staple"
|
||||||
|
).expect("Failed to load SSH key")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_file_rsa_plain() {
|
||||||
|
let k = rsa_plain();
|
||||||
|
assert_eq!(k.algorithm.as_str(), "ssh-rsa");
|
||||||
|
assert_eq!(&k.comment, "hello world");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
k.public_key.fingerprint(Default::default()),
|
||||||
|
k.private_key.fingerprint(Default::default()),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
k.private_key.fingerprint(Default::default()).as_bytes(),
|
||||||
|
[90,162,92,235,160,164,88,179,144,234,84,135,1,249,9,206,
|
||||||
|
201,172,233,129,82,11,145,191,186,144,209,43,81,119,197,18],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_file_rsa_enc() {
|
||||||
|
let k = rsa_enc();
|
||||||
|
assert_eq!(k.algorithm.as_str(), "ssh-rsa");
|
||||||
|
assert_eq!(&k.comment, "hello world");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
k.public_key.fingerprint(Default::default()),
|
||||||
|
k.private_key.fingerprint(Default::default()),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
k.private_key.fingerprint(Default::default()).as_bytes(),
|
||||||
|
[254,147,219,185,96,234,125,190,195,128,37,243,214,193,8,162,
|
||||||
|
34,237,126,199,241,91,195,251,232,84,144,120,25,63,224,157],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_file_ed25519_plain() {
|
||||||
|
let k = ed25519_plain();
|
||||||
|
assert_eq!(k.algorithm.as_str(),"ssh-ed25519");
|
||||||
|
assert_eq!(&k.comment, "hello world");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
k.public_key.fingerprint(Default::default()),
|
||||||
|
k.private_key.fingerprint(Default::default()),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
k.private_key.fingerprint(Default::default()).as_bytes(),
|
||||||
|
[29,30,193,72,239,167,35,89,1,206,126,186,123,112,78,187,
|
||||||
|
240,59,1,15,107,189,72,30,44,64,114,216,32,195,22,201],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_file_ed25519_enc() {
|
||||||
|
let k = ed25519_enc();
|
||||||
|
assert_eq!(k.algorithm.as_str(), "ssh-ed25519");
|
||||||
|
assert_eq!(&k.comment, "hello world");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
k.public_key.fingerprint(Default::default()),
|
||||||
|
k.private_key.fingerprint(Default::default()),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
k.private_key.fingerprint(Default::default()).as_bytes(),
|
||||||
|
[87,233,161,170,18,47,245,116,30,177,120,211,248,54,65,255,
|
||||||
|
41,45,113,107,182,221,189,167,110,9,245,254,44,6,118,141],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize() {
|
||||||
|
let expected = fs::read_to_string(path("ssh_ed25519_plain.json")).unwrap();
|
||||||
|
|
||||||
|
let k = ed25519_plain();
|
||||||
|
let computed = serde_json::to_string(&k)
|
||||||
|
.expect("Failed to serialize SshKey");
|
||||||
|
|
||||||
|
assert_eq!(expected, computed);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize() {
|
||||||
|
let expected = ed25519_plain();
|
||||||
|
|
||||||
|
let json_file = File::open(path("ssh_ed25519_plain.json")).unwrap();
|
||||||
|
let computed = serde_json::from_reader(json_file)
|
||||||
|
.expect("Failed to deserialize json file");
|
||||||
|
|
||||||
|
assert_eq!(expected, computed);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_save_db(pool: SqlitePool) {
|
||||||
|
let crypto = Crypto::random();
|
||||||
|
let record = CredentialRecord {
|
||||||
|
id: random_uuid(),
|
||||||
|
name: "save_test".into(),
|
||||||
|
is_default: false,
|
||||||
|
credential: Credential::Ssh(rsa_plain()),
|
||||||
|
};
|
||||||
|
record.save(&crypto, &pool).await
|
||||||
|
.expect("Failed to save SSH key CredentialRecord to database");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("ssh_credentials"))]
|
||||||
|
async fn test_load_db(pool: SqlitePool) {
|
||||||
|
let crypto = Crypto::fixed();
|
||||||
|
let id = uuid!("11111111-1111-1111-1111-111111111111");
|
||||||
|
SshKey::load(&id, &crypto, &pool).await
|
||||||
|
.expect("Failed to load SSH key from database");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_save_load_db(pool: SqlitePool) {
|
||||||
|
let crypto = Crypto::random();
|
||||||
|
|
||||||
|
let id = random_uuid();
|
||||||
|
let record = CredentialRecord {
|
||||||
|
id,
|
||||||
|
name: "save_load_test".into(),
|
||||||
|
is_default: false,
|
||||||
|
credential: Credential::Ssh(ed25519_plain()),
|
||||||
|
};
|
||||||
|
|
||||||
|
record.save(&crypto, &pool).await.unwrap();
|
||||||
|
let loaded = SshKey::load(&id, &crypto, &pool).await.unwrap();
|
||||||
|
let known = ed25519_plain();
|
||||||
|
|
||||||
|
assert_eq!(known.algorithm, loaded.algorithm);
|
||||||
|
assert_eq!(known.comment, loaded.comment);
|
||||||
|
// comment gets stripped by saving as bytes, so we just compare raw key data
|
||||||
|
assert_eq!(known.public_key.key_data(), loaded.public_key.key_data());
|
||||||
|
assert_eq!(known.private_key, loaded.private_key);
|
||||||
|
}
|
||||||
|
}
|
554
src-tauri/src/errors.rs
Normal file
554
src-tauri/src/errors.rs
Normal file
@ -0,0 +1,554 @@
|
|||||||
|
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::{
|
||||||
|
error::SdkError as AwsSdkError,
|
||||||
|
operation::get_session_token::GetSessionTokenError,
|
||||||
|
error::ProvideErrorMetadata,
|
||||||
|
};
|
||||||
|
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
|
||||||
|
#[error("Error from database: {0}")]
|
||||||
|
DbError(#[from] SqlxError),
|
||||||
|
#[error("Error loading data: {0}")]
|
||||||
|
KvError(#[from] LoadKvError),
|
||||||
|
#[error("Error running migrations: {0}")]
|
||||||
|
MigrationError(#[from] MigrateError),
|
||||||
|
#[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),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum DataDirError {
|
||||||
|
#[error("Could not determine data directory")]
|
||||||
|
NotFound,
|
||||||
|
#[error("Failed to create data directory: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// error when attempting to tell a request handler whether to release or deny credentials
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum SendResponseError {
|
||||||
|
#[error("The specified credentials request was not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("The specified request was already closed by the client")]
|
||||||
|
Abandoned,
|
||||||
|
#[error("A response has already been received for the specified request")]
|
||||||
|
Fulfilled,
|
||||||
|
#[error("Could not renew AWS sesssion: {0}")]
|
||||||
|
SessionRenew(#[from] GetSessionError),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// errors encountered while handling 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,
|
||||||
|
#[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,
|
||||||
|
#[error(transparent)]
|
||||||
|
SshAgent(#[from] ssh_agent_lib::error::AgentError),
|
||||||
|
#[error(transparent)]
|
||||||
|
SshKey(#[from] ssh_key::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Signature(#[from] signature::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Encoding(#[from] ssh_encoding::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
#[error(transparent)]
|
||||||
|
Crypto(#[from] CryptoError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Load(#[from] LoadCredentialsError),
|
||||||
|
#[error(transparent)]
|
||||||
|
GetSession(#[from] GetSessionError),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
#[error(transparent)]
|
||||||
|
Crypto(#[from] CryptoError),
|
||||||
|
#[error("Data was found to be corrupt after decryption")]
|
||||||
|
InvalidUtf8, // Somehow we got invalid utf-8 even though decryption succeeded
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
DbError(#[from] SqlxError),
|
||||||
|
#[error("Failed to create AWS session: {0}")]
|
||||||
|
GetSession(#[from] GetSessionError),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum LockError {
|
||||||
|
#[error("App is not unlocked")]
|
||||||
|
NotUnlocked,
|
||||||
|
#[error(transparent)]
|
||||||
|
LoadCredentials(#[from] LoadCredentialsError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Setup(#[from] SetupError),
|
||||||
|
#[error(transparent)]
|
||||||
|
TauriError(#[from] tauri::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Crypto(#[from] CryptoError),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum SaveCredentialsError {
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
DbError(#[from] SqlxError),
|
||||||
|
#[error("Encryption error: {0}")]
|
||||||
|
Crypto(#[from] CryptoError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Session(#[from] GetCredentialsError),
|
||||||
|
#[error("App is locked")]
|
||||||
|
Locked,
|
||||||
|
#[error("Credential is temporary and cannot be saved")]
|
||||||
|
NotPersistent,
|
||||||
|
#[error("A credential with that name already exists")]
|
||||||
|
Duplicate,
|
||||||
|
#[error("Failed to save credentials: {0}")]
|
||||||
|
Encode(#[from] ssh_key::Error),
|
||||||
|
// rekeying is fundamentally a save operation,
|
||||||
|
// but involves loading in order to re-save
|
||||||
|
#[error(transparent)]
|
||||||
|
LoadCredentials(#[from] LoadCredentialsError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum LoadCredentialsError {
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
DbError(#[from] SqlxError),
|
||||||
|
#[error("Invalid passphrase")] // pretty sure this is the only way decryption fails
|
||||||
|
Encryption(#[from] CryptoError),
|
||||||
|
#[error("Credentials not found")]
|
||||||
|
NoCredentials,
|
||||||
|
#[error("Could not decode credential data")]
|
||||||
|
InvalidData,
|
||||||
|
#[error(transparent)]
|
||||||
|
LoadKv(#[from] LoadKvError),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum LoadKvError {
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
DbError(#[from] SqlxError),
|
||||||
|
#[error("Could not parse value from database: {0}")]
|
||||||
|
Invalid(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[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),
|
||||||
|
#[error("App is currently locked")]
|
||||||
|
Locked,
|
||||||
|
#[error("No passphrase has been specified")]
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Errors encountered while trying to figure out who's on the other end of a request
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum ClientInfoError {
|
||||||
|
#[error("Found PID for client socket, but no corresponding process")]
|
||||||
|
ProcessNotFound,
|
||||||
|
#[error("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("Could not determine PID of connected client")]
|
||||||
|
PidNotFound,
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Technically also an error, but formatted as a struct for easy deserialization
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ServerError {
|
||||||
|
code: String,
|
||||||
|
msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ServerError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
|
||||||
|
write!(f, "{} ({})", self.msg, self.code)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Errors encountered while requesting credentials via CLI (creddy show, creddy exec)
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum RequestError {
|
||||||
|
#[error("Error response from server: {0}")]
|
||||||
|
Server(ServerError),
|
||||||
|
#[error("Unexpected response from server")]
|
||||||
|
Unexpected(crate::srv::CliResponse),
|
||||||
|
#[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),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum LoadSshKeyError {
|
||||||
|
#[error("Passphrase is invalid")]
|
||||||
|
InvalidPassphrase,
|
||||||
|
#[error("Could not parse SSH private key data")]
|
||||||
|
InvalidData(#[from] ssh_key::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Serialize implementations
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
|
||||||
|
struct SerializeWrapper<E>(pub E);
|
||||||
|
|
||||||
|
impl Serialize for SerializeWrapper<&GetSessionTokenError> {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let err = self.0;
|
||||||
|
let mut map = serializer.serialize_map(None)?;
|
||||||
|
map.serialize_entry("code", &err.code())?;
|
||||||
|
map.serialize_entry("msg", &err.message())?;
|
||||||
|
map.serialize_entry("source", &None::<&str>)?;
|
||||||
|
map.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl_serialize_basic!(SetupError);
|
||||||
|
impl_serialize_basic!(GetCredentialsError);
|
||||||
|
impl_serialize_basic!(ClientInfoError);
|
||||||
|
impl_serialize_basic!(WindowError);
|
||||||
|
impl_serialize_basic!(LockError);
|
||||||
|
impl_serialize_basic!(SaveCredentialsError);
|
||||||
|
impl_serialize_basic!(LoadCredentialsError);
|
||||||
|
impl_serialize_basic!(LoadSshKeyError);
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
13
src-tauri/src/fixtures/kv.sql
Normal file
13
src-tauri/src/fixtures/kv.sql
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
INSERT INTO kv (name, value)
|
||||||
|
VALUES
|
||||||
|
-- b"hello world" (raw bytes)
|
||||||
|
('test_bytes', X'68656C6C6F20776F726C64'),
|
||||||
|
|
||||||
|
-- b"\"hello world\"" (JSON string)
|
||||||
|
('test_string', X'2268656C6C6F20776F726C6422'),
|
||||||
|
|
||||||
|
-- b"123" (JSON integer)
|
||||||
|
('test_int', X'313233'),
|
||||||
|
|
||||||
|
-- b"true" (JSON bool)
|
||||||
|
('test_bool', X'74727565')
|
@ -1,42 +0,0 @@
|
|||||||
use std::fmt::{Display, Formatter};
|
|
||||||
use std::convert::From;
|
|
||||||
use std::str::Utf8Error;
|
|
||||||
|
|
||||||
// use tokio::sync::oneshot::error::RecvError;
|
|
||||||
|
|
||||||
|
|
||||||
// Represents errors encountered while handling an HTTP request
|
|
||||||
pub enum RequestError {
|
|
||||||
StreamIOError(std::io::Error),
|
|
||||||
InvalidUtf8,
|
|
||||||
MalformedHttpRequest,
|
|
||||||
RequestTooLarge,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<tokio::io::Error> for RequestError {
|
|
||||||
fn from(e: std::io::Error) -> RequestError {
|
|
||||||
RequestError::StreamIOError(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl From<Utf8Error> for RequestError {
|
|
||||||
fn from(_e: Utf8Error) -> RequestError {
|
|
||||||
RequestError::InvalidUtf8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// impl From<RecvError> for RequestError {
|
|
||||||
// fn from (_e: RecvError) -> RequestError {
|
|
||||||
// RequestError::
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
impl Display for RequestError {
|
|
||||||
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
|
|
||||||
use RequestError::*;
|
|
||||||
match self {
|
|
||||||
StreamIOError(e) => write!(f, "Stream IO error: {e}"),
|
|
||||||
InvalidUtf8 => write!(f, "Could not decode UTF-8 from bytestream"),
|
|
||||||
MalformedHttpRequest => write!(f, "Maformed HTTP request"),
|
|
||||||
RequestTooLarge => write!(f, "HTTP request too large"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,99 +0,0 @@
|
|||||||
use std::io;
|
|
||||||
use std::net::SocketAddrV4;
|
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
|
|
||||||
use tauri::{AppHandle, Manager};
|
|
||||||
|
|
||||||
mod errors;
|
|
||||||
use errors::RequestError;
|
|
||||||
|
|
||||||
|
|
||||||
pub async fn serve(addr: SocketAddrV4, app_handle: AppHandle) -> io::Result<()> {
|
|
||||||
let listener = TcpListener::bind(&addr).await?;
|
|
||||||
println!("Listening on {addr}");
|
|
||||||
loop {
|
|
||||||
let new_handle = app_handle.app_handle();
|
|
||||||
match listener.accept().await {
|
|
||||||
Ok((stream, _)) => {
|
|
||||||
tokio::spawn(async {
|
|
||||||
if let Err(e) = handle(stream, new_handle).await {
|
|
||||||
eprintln!("{e}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
println!("Error accepting connection: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// it doesn't really return a String, we just need to placate the compiler
|
|
||||||
async fn stall(stream: &mut TcpStream) -> Result<String, tokio::io::Error> {
|
|
||||||
let delay = std::time::Duration::from_secs(1);
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(delay).await;
|
|
||||||
stream.write(b"x").await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async fn handle(mut stream: TcpStream, app_handle: AppHandle) -> Result<(), RequestError> {
|
|
||||||
let mut buf = [0; 8192]; // it's what tokio's BufReader uses
|
|
||||||
let mut n = 0;
|
|
||||||
loop {
|
|
||||||
n += stream.read(&mut buf[n..]).await?;
|
|
||||||
if &buf[(n - 4)..n] == b"\r\n\r\n" {break;}
|
|
||||||
if n == buf.len() {return Err(RequestError::RequestTooLarge);}
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{}", std::str::from_utf8(&buf).unwrap());
|
|
||||||
|
|
||||||
stream.write(b"HTTP/1.0 200 OK\r\n").await?;
|
|
||||||
stream.write(b"Content-Type: application/json\r\n").await?;
|
|
||||||
stream.write(b"X-Creddy-delaying-tactic: ").await?;
|
|
||||||
|
|
||||||
let creds = tokio::select!{
|
|
||||||
r = stall(&mut stream) => r?, // this will never return Ok, just Err if it can't write to the stream
|
|
||||||
c = get_creds(&app_handle) => c?,
|
|
||||||
};
|
|
||||||
|
|
||||||
stream.write(b"\r\nContent-Length: ").await?;
|
|
||||||
stream.write(creds.as_bytes().len().to_string().as_bytes()).await?;
|
|
||||||
stream.write(b"\r\n\r\n").await?;
|
|
||||||
stream.write(creds.as_bytes()).await?;
|
|
||||||
stream.write(b"\r\n\r\n").await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
use tokio::io::{stdin, stdout, BufReader, AsyncBufReadExt};
|
|
||||||
use crate::storage;
|
|
||||||
|
|
||||||
use tokio::sync::oneshot;
|
|
||||||
|
|
||||||
async fn get_creds(app_handle: &AppHandle) -> io::Result<String> {
|
|
||||||
app_handle.emit_all("credentials-request", ()).unwrap();
|
|
||||||
|
|
||||||
// let mut out = stdout();
|
|
||||||
// out.write_all(b"Enter passphrase: ").await?;
|
|
||||||
// out.flush().await?;
|
|
||||||
|
|
||||||
let (tx, rx) = oneshot::channel();
|
|
||||||
app_handle.once_global("passphrase-entered", |event| {
|
|
||||||
match event.payload() {
|
|
||||||
Some(p) => {tx.send(p.to_string());}
|
|
||||||
None => {tx.send("".to_string());} // will fail decryption, we just need to unblock the outer function
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Error is only returned if the rx is closed/dropped before receiving, which should never happen
|
|
||||||
let passphrase = rx.await.unwrap();
|
|
||||||
|
|
||||||
// let mut passphrase = String::new();
|
|
||||||
// let mut reader = BufReader::new(stdin());
|
|
||||||
// reader.read_line(&mut passphrase).await?;
|
|
||||||
|
|
||||||
Ok(storage::load(&passphrase.trim()))
|
|
||||||
}
|
|
183
src-tauri/src/ipc.rs
Normal file
183
src-tauri/src/ipc.rs
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use sqlx::types::Uuid;
|
||||||
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::credentials::{
|
||||||
|
AppSession,
|
||||||
|
CredentialRecord,
|
||||||
|
SshKey,
|
||||||
|
};
|
||||||
|
use crate::errors::*;
|
||||||
|
use crate::clientinfo::Client;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::terminal;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AwsRequestNotification {
|
||||||
|
pub id: u64,
|
||||||
|
pub client: Client,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub base: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SshRequestNotification {
|
||||||
|
pub id: u64,
|
||||||
|
pub client: Client,
|
||||||
|
pub key_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum RequestNotification {
|
||||||
|
Aws(AwsRequestNotification),
|
||||||
|
Ssh(SshRequestNotification),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestNotification {
|
||||||
|
pub fn new_aws(id: u64, client: Client, name: Option<String>, base: bool) -> Self {
|
||||||
|
Self::Aws(AwsRequestNotification {id, client, name, base})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_ssh(id: u64, client: Client, key_name: String) -> Self {
|
||||||
|
Self::Ssh(SshRequestNotification {id, client, key_name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RequestResponse {
|
||||||
|
pub id: u64,
|
||||||
|
pub approval: Approval,
|
||||||
|
pub base: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub enum Approval {
|
||||||
|
Approved,
|
||||||
|
Denied,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn respond(response: RequestResponse, app_state: State<'_, AppState>) -> Result<(), SendResponseError> {
|
||||||
|
app_state.send_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Result<(), UnlockError> {
|
||||||
|
app_state.unlock(&passphrase).await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn lock(app_state: State<'_, AppState>) -> Result<(), LockError> {
|
||||||
|
app_state.lock().await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reset_session(app_state: State<'_, AppState>) -> Result<(), SaveCredentialsError> {
|
||||||
|
app_state.reset_session().await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_passphrase(passphrase: &str, app_state: State<'_, AppState>) -> Result<(), SaveCredentialsError> {
|
||||||
|
app_state.set_passphrase(passphrase).await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String, ()> {
|
||||||
|
let session = app_state.app_session.read().await;
|
||||||
|
let status = match *session {
|
||||||
|
AppSession::Locked{..} => "locked".into(),
|
||||||
|
AppSession::Unlocked{..} => "unlocked".into(),
|
||||||
|
AppSession::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_credential(
|
||||||
|
record: CredentialRecord,
|
||||||
|
app_state: State<'_, AppState>
|
||||||
|
) -> Result<(), SaveCredentialsError> {
|
||||||
|
app_state.save_credential(record).await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_credential(id: &str, app_state: State<'_, AppState>) -> Result<(), SaveCredentialsError> {
|
||||||
|
let id = Uuid::try_parse(id)
|
||||||
|
.map_err(|_| LoadCredentialsError::NoCredentials)?;
|
||||||
|
app_state.delete_credential(&id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_credentials(app_state: State<'_, AppState>) -> Result<Vec<CredentialRecord>, GetCredentialsError> {
|
||||||
|
app_state.list_credentials().await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sshkey_from_file(path: &str, passphrase: &str) -> Result<SshKey, LoadSshKeyError> {
|
||||||
|
SshKey::from_file(path, passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sshkey_from_private_key(private_key: &str, passphrase: &str) -> Result<SshKey, LoadSshKeyError> {
|
||||||
|
SshKey::from_private_key(private_key, passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[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> {
|
||||||
|
let res = terminal::launch(base).await;
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_setup_errors(app_state: State<'_, AppState>) -> Result<Vec<String>, ()> {
|
||||||
|
Ok(app_state.setup_errors.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn exit(app_handle: AppHandle) {
|
||||||
|
app_handle.exit(0)
|
||||||
|
}
|
212
src-tauri/src/kv.rs
Normal file
212
src-tauri/src/kv.rs
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::errors::*;
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn save<T>(pool: &SqlitePool, name: &str, value: &T) -> Result<(), sqlx::Error>
|
||||||
|
where T: Serialize + ?Sized
|
||||||
|
{
|
||||||
|
let bytes = serde_json::to_vec(value).unwrap();
|
||||||
|
save_bytes(pool, name, &bytes).await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn save_bytes(pool: &SqlitePool, name: &str, bytes: &[u8]) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO kv (name, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET value = excluded.value;",
|
||||||
|
name,
|
||||||
|
bytes,
|
||||||
|
).execute(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn load<T>(pool: &SqlitePool, name: &str) -> Result<Option<T>, LoadKvError>
|
||||||
|
where T: DeserializeOwned
|
||||||
|
{
|
||||||
|
let v = load_bytes(pool, name)
|
||||||
|
.await?
|
||||||
|
.map(|bytes| serde_json::from_slice(&bytes))
|
||||||
|
.transpose()?;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn load_bytes(pool: &SqlitePool, name: &str) -> Result<Option<Vec<u8>>, sqlx::Error> {
|
||||||
|
sqlx::query!("SELECT name, value FROM kv WHERE name = ?", name)
|
||||||
|
.map(|row| row.value)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map(|o| o.flatten())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// we don't have a need for this right now, but we will some day
|
||||||
|
#[cfg(test)]
|
||||||
|
pub async fn delete(pool: &SqlitePool, name: &str) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query!("DELETE FROM kv WHERE name = ?", name)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn delete_multi(pool: &SqlitePool, names: &[&str]) -> Result<(), sqlx::Error> {
|
||||||
|
let placeholder = names.iter()
|
||||||
|
.map(|_| "?")
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.join(",");
|
||||||
|
let query = format!("DELETE FROM kv WHERE name IN ({})", placeholder);
|
||||||
|
|
||||||
|
let mut q = sqlx::query(&query);
|
||||||
|
for name in names {
|
||||||
|
q = q.bind(name);
|
||||||
|
}
|
||||||
|
q.execute(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
macro_rules! load_bytes_multi {
|
||||||
|
(
|
||||||
|
$pool:expr,
|
||||||
|
$($name:literal),*
|
||||||
|
) => {
|
||||||
|
// wrap everything up in an async block for easy short-circuiting...
|
||||||
|
async {
|
||||||
|
// ...returning a Result...
|
||||||
|
Ok::<_, sqlx::Error>(
|
||||||
|
//containing an Option...
|
||||||
|
Some(
|
||||||
|
// containing a tuple...
|
||||||
|
(
|
||||||
|
// ...with one item for each repetition of $name
|
||||||
|
$(
|
||||||
|
// load_bytes returns Result<Option<_>>, the Result is handled by
|
||||||
|
// the ? and we match on the Option
|
||||||
|
match crate::kv::load_bytes($pool, $name).await? {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return Ok(None)
|
||||||
|
},
|
||||||
|
)*
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) use load_bytes_multi;
|
||||||
|
|
||||||
|
|
||||||
|
// macro_rules! load_multi {
|
||||||
|
// (
|
||||||
|
// $pool:expr,
|
||||||
|
// $($name:literal),*
|
||||||
|
// ) => {
|
||||||
|
// (|| {
|
||||||
|
// (
|
||||||
|
// $(
|
||||||
|
// match load(pool, $name)? {
|
||||||
|
// Some(v) => v,
|
||||||
|
// None => return Ok(None)
|
||||||
|
// },
|
||||||
|
// )*
|
||||||
|
// )
|
||||||
|
// })()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_save_bytes(pool: SqlitePool) {
|
||||||
|
save_bytes(&pool, "test_bytes", b"hello world").await
|
||||||
|
.expect("Failed to save bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_save(pool: SqlitePool) {
|
||||||
|
save(&pool, "test_string", "hello world").await
|
||||||
|
.expect("Failed to save string");
|
||||||
|
save(&pool, "test_int", &123).await
|
||||||
|
.expect("Failed to save integer");
|
||||||
|
save(&pool, "test_bool", &true).await
|
||||||
|
.expect("Failed to save bool");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("kv"))]
|
||||||
|
async fn test_load_bytes(pool: SqlitePool) {
|
||||||
|
let bytes = load_bytes(&pool, "test_bytes").await
|
||||||
|
.expect("Failed to load bytes")
|
||||||
|
.expect("Test data not found in database");
|
||||||
|
|
||||||
|
assert_eq!(bytes, Vec::from(b"hello world"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("kv"))]
|
||||||
|
async fn test_load(pool: SqlitePool) {
|
||||||
|
let string: String = load(&pool, "test_string").await
|
||||||
|
.expect("Failed to load string")
|
||||||
|
.expect("Test data not found in database");
|
||||||
|
assert_eq!(string, "hello world".to_string());
|
||||||
|
|
||||||
|
let integer: usize = load(&pool, "test_int").await
|
||||||
|
.expect("Failed to load integer")
|
||||||
|
.expect("Test data not found in database");
|
||||||
|
assert_eq!(integer, 123);
|
||||||
|
|
||||||
|
let boolean: bool = load(&pool, "test_bool").await
|
||||||
|
.expect("Failed to load boolean")
|
||||||
|
.expect("Test data not found in database");
|
||||||
|
assert_eq!(boolean, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("kv"))]
|
||||||
|
async fn test_load_multi(pool: SqlitePool) {
|
||||||
|
let (bytes, boolean) = load_bytes_multi!(&pool, "test_bytes", "test_bool")
|
||||||
|
.await
|
||||||
|
.expect("Failed to load items")
|
||||||
|
.expect("Test data not found in database");
|
||||||
|
|
||||||
|
assert_eq!(bytes, Vec::from(b"hello world"));
|
||||||
|
assert_eq!(boolean, Vec::from(b"true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("kv"))]
|
||||||
|
async fn test_delete(pool: SqlitePool) {
|
||||||
|
delete(&pool, "test_bytes").await
|
||||||
|
.expect("Failed to delete data");
|
||||||
|
|
||||||
|
let loaded = load_bytes(&pool, "test_bytes").await
|
||||||
|
.expect("Failed to load data");
|
||||||
|
assert_eq!(loaded, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("kv"))]
|
||||||
|
async fn test_delete_multi(pool: SqlitePool) {
|
||||||
|
delete_multi(&pool, &["test_bytes", "test_string"]).await
|
||||||
|
.expect("Failed to delete keys");
|
||||||
|
|
||||||
|
let bytes_opt = load_bytes(&pool, "test_bytes").await
|
||||||
|
.expect("Failed to load bytes");
|
||||||
|
assert_eq!(bytes_opt, None);
|
||||||
|
|
||||||
|
let string_opt = load_bytes(&pool, "test_string").await
|
||||||
|
.expect("Failed to load string");
|
||||||
|
assert_eq!(string_opt, None);
|
||||||
|
}
|
||||||
|
}
|
12
src-tauri/src/lib.rs
Normal file
12
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
pub mod app;
|
||||||
|
mod config;
|
||||||
|
mod credentials;
|
||||||
|
pub mod errors;
|
||||||
|
mod clientinfo;
|
||||||
|
mod ipc;
|
||||||
|
mod kv;
|
||||||
|
mod state;
|
||||||
|
mod srv;
|
||||||
|
mod shortcuts;
|
||||||
|
mod terminal;
|
||||||
|
mod tray;
|
@ -3,28 +3,28 @@
|
|||||||
windows_subsystem = "windows"
|
windows_subsystem = "windows"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
use std::str::FromStr;
|
|
||||||
// use tokio::runtime::Runtime;
|
|
||||||
|
|
||||||
mod storage;
|
use creddy::{
|
||||||
mod http;
|
app,
|
||||||
|
errors::ShowError,
|
||||||
|
};
|
||||||
|
use creddy_cli::{Action, Cli};
|
||||||
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
let cli = Cli::parse();
|
||||||
.setup(|app| {
|
let res = match cli.action {
|
||||||
let addr = std::net::SocketAddrV4::from_str("127.0.0.1:12345").unwrap();
|
None | Some(Action::Run) => {
|
||||||
tauri::async_runtime::spawn(http::serve(addr, app.handle()));
|
app::run().error_popup("Creddy encountered an error");
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
},
|
||||||
.run(tauri::generate_context!())
|
Some(Action::Get(args)) => creddy_cli::get(args, cli.global_args),
|
||||||
.expect("error while running tauri application");
|
Some(Action::Exec(args)) => creddy_cli::exec(args, cli.global_args),
|
||||||
|
Some(Action::Shortcut(args)) => creddy_cli::invoke_shortcut(args, cli.global_args),
|
||||||
|
};
|
||||||
|
|
||||||
// let addr = std::net::SocketAddrV4::from_str("127.0.0.1:12345").unwrap();
|
if let Err(e) = res {
|
||||||
// let rt = Runtime::new().unwrap();
|
eprintln!("Error: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
// rt.block_on(http::serve(addr)).unwrap();
|
}
|
||||||
|
|
||||||
// let creds = std::fs::read_to_string("credentials.json").unwrap();
|
|
||||||
// storage::save(&creds, "correct horse battery staple");
|
|
||||||
}
|
}
|
||||||
|
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(())
|
||||||
|
}
|
115
src-tauri/src/srv/agent.rs
Normal file
115
src-tauri/src/srv/agent.rs
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
use futures::SinkExt;
|
||||||
|
use ssh_agent_lib::agent::MessageCodec;
|
||||||
|
use ssh_agent_lib::proto::message::{
|
||||||
|
Message,
|
||||||
|
SignRequest,
|
||||||
|
};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
use tokio_util::codec::Framed;
|
||||||
|
|
||||||
|
use crate::clientinfo;
|
||||||
|
use crate::errors::*;
|
||||||
|
use crate::ipc::{Approval, RequestNotification};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::{CloseWaiter, Stream};
|
||||||
|
|
||||||
|
|
||||||
|
pub fn serve(app_handle: AppHandle) -> std::io::Result<()> {
|
||||||
|
super::serve("creddy-agent", app_handle, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn handle(
|
||||||
|
stream: Stream,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
client_pid: u32
|
||||||
|
) -> Result<(), HandlerError> {
|
||||||
|
let mut adapter = Framed::new(stream, MessageCodec);
|
||||||
|
while let Some(message) = adapter.try_next().await? {
|
||||||
|
match message {
|
||||||
|
Message::RequestIdentities => {
|
||||||
|
let resp = list_identities(app_handle.clone()).await?;
|
||||||
|
adapter.send(resp).await?;
|
||||||
|
},
|
||||||
|
Message::SignRequest(req) => {
|
||||||
|
// Note: If the client writes more data to the stream *while* at the
|
||||||
|
// same time waiting for a resopnse to a previous request, this will
|
||||||
|
// corrupt the framing. Clients don't seem to behave that way though?
|
||||||
|
let waiter = CloseWaiter { stream: adapter.get_mut() };
|
||||||
|
let resp = sign_request(req, app_handle.clone(), client_pid, waiter).await?;
|
||||||
|
|
||||||
|
// have to do this before we send since we can't inspect the message after
|
||||||
|
let is_failure = matches!(resp, Message::Failure);
|
||||||
|
adapter.send(resp).await?;
|
||||||
|
|
||||||
|
if is_failure {
|
||||||
|
// this way we don't get spammed with requests for other keys
|
||||||
|
// after denying the first
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => adapter.send(Message::Failure).await?,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn list_identities(app_handle: AppHandle) -> Result<Message, HandlerError> {
|
||||||
|
let state = app_handle.state::<AppState>();
|
||||||
|
let identities = state.list_ssh_identities().await?;
|
||||||
|
Ok(Message::IdentitiesAnswer(identities))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn sign_request(
|
||||||
|
req: SignRequest,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
client_pid: u32,
|
||||||
|
mut waiter: CloseWaiter<'_>,
|
||||||
|
) -> Result<Message, HandlerError> {
|
||||||
|
let state = app_handle.state::<AppState>();
|
||||||
|
let rehide_ms = {
|
||||||
|
let config = state.config.read().await;
|
||||||
|
config.rehide_ms
|
||||||
|
};
|
||||||
|
let client = clientinfo::get_client(client_pid, false)?;
|
||||||
|
let lease = state.acquire_visibility_lease(rehide_ms).await
|
||||||
|
.map_err(|_e| HandlerError::NoMainWindow)?;
|
||||||
|
|
||||||
|
let (chan_send, chan_recv) = oneshot::channel();
|
||||||
|
let request_id = state.register_request(chan_send).await;
|
||||||
|
|
||||||
|
let proceed = async {
|
||||||
|
let key_name = state.ssh_name_from_pubkey(&req.pubkey_blob).await?;
|
||||||
|
let notification = RequestNotification::new_ssh(request_id, client, key_name.clone());
|
||||||
|
app_handle.emit("credential-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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Approval::Denied = response.approval {
|
||||||
|
return Ok(Message::Failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = state.sshkey_by_name(&key_name).await?;
|
||||||
|
let sig = key.sign_request(&req)?;
|
||||||
|
Ok(Message::SignResponse(sig))
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = proceed.await;
|
||||||
|
if let Err(_) = &res {
|
||||||
|
state.unregister_request(request_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
lease.release();
|
||||||
|
res
|
||||||
|
}
|
132
src-tauri/src/srv/creddy_server.rs
Normal file
132
src-tauri/src/srv/creddy_server.rs
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
|
use crate::clientinfo::{self, Client};
|
||||||
|
use crate::errors::*;
|
||||||
|
use crate::ipc::{Approval, RequestNotification};
|
||||||
|
use crate::shortcuts::{self, ShortcutAction};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use super::{
|
||||||
|
CloseWaiter,
|
||||||
|
CliCredential,
|
||||||
|
CliRequest,
|
||||||
|
CliResponse,
|
||||||
|
Stream,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
pub fn serve(app_handle: AppHandle) -> std::io::Result<()> {
|
||||||
|
super::serve("creddy-server", app_handle, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// sanity check, no request should ever be within a mile of 1MB
|
||||||
|
else if n >= (1024 * 1024) {
|
||||||
|
return Err(HandlerError::RequestTooLarge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = clientinfo::get_client(client_pid, true)?;
|
||||||
|
let waiter = CloseWaiter { stream: &mut stream };
|
||||||
|
|
||||||
|
|
||||||
|
let req: CliRequest = serde_json::from_slice(&buf)?;
|
||||||
|
let res = match req {
|
||||||
|
CliRequest::GetCredential{ name, base } => get_aws_credentials(
|
||||||
|
name, base, client, app_handle, waiter
|
||||||
|
).await,
|
||||||
|
CliRequest::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<CliResponse, HandlerError> {
|
||||||
|
shortcuts::exec_shortcut(action);
|
||||||
|
Ok(CliResponse::Empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn get_aws_credentials(
|
||||||
|
name: Option<String>,
|
||||||
|
base: bool,
|
||||||
|
client: Client,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
mut waiter: CloseWaiter<'_>,
|
||||||
|
) -> Result<CliResponse, 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 = RequestNotification::new_aws(
|
||||||
|
request_id, client, name.clone(), base
|
||||||
|
);
|
||||||
|
app_handle.emit("credential-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.get_aws_base(name).await?;
|
||||||
|
Ok(CliResponse::Credential(CliCredential::AwsBase(creds)))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let creds = state.get_aws_session(name).await?.clone();
|
||||||
|
Ok(CliResponse::Credential(CliCredential::AwsSession(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
|
||||||
|
}
|
164
src-tauri/src/srv/mod.rs
Normal file
164
src-tauri/src/srv/mod.rs
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
use tauri::{
|
||||||
|
AppHandle,
|
||||||
|
async_runtime as rt,
|
||||||
|
};
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
|
use crate::credentials::{AwsBaseCredential, AwsSessionCredential};
|
||||||
|
use crate::errors::*;
|
||||||
|
use crate::shortcuts::ShortcutAction;
|
||||||
|
|
||||||
|
pub mod creddy_server;
|
||||||
|
pub mod agent;
|
||||||
|
use platform::Stream;
|
||||||
|
|
||||||
|
|
||||||
|
// These types match what's defined in creddy_cli, but they are separate types
|
||||||
|
// so that we avoid polluting the standalone CLI with a bunch of dependencies
|
||||||
|
// that would make it impossible to build a completely static-linked version
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub enum CliRequest {
|
||||||
|
GetCredential {
|
||||||
|
name: Option<String>,
|
||||||
|
base: bool,
|
||||||
|
},
|
||||||
|
InvokeShortcut(ShortcutAction),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub enum CliResponse {
|
||||||
|
Credential(CliCredential),
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub enum CliCredential {
|
||||||
|
AwsBase(AwsBaseCredential),
|
||||||
|
AwsSession(AwsSessionCredential),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn serve<H, F>(sock_name: &str, app_handle: AppHandle, handler: H) -> std::io::Result<()>
|
||||||
|
where H: Copy + Send + Fn(Stream, AppHandle, u32) -> F + 'static,
|
||||||
|
F: Send + Future<Output = Result<(), HandlerError>>,
|
||||||
|
{
|
||||||
|
let (mut listener, addr) = platform::bind(sock_name)?;
|
||||||
|
rt::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let (stream, client_pid) = match platform::accept(&mut listener, &addr).await {
|
||||||
|
Ok((s, c)) => (s, c),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error accepting request: {e}");
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let new_handle = app_handle.clone();
|
||||||
|
rt::spawn(async move {
|
||||||
|
handler(stream, new_handle, client_pid)
|
||||||
|
.await
|
||||||
|
.error_print_prefix("Error responding to request: ");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod platform {
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::net::{UnixListener, UnixStream};
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
pub type Stream = UnixStream;
|
||||||
|
|
||||||
|
pub fn bind(sock_name: &str) -> std::io::Result<(UnixListener, PathBuf)> {
|
||||||
|
let path = creddy_cli::server_addr(sock_name);
|
||||||
|
match std::fs::remove_file(&path) {
|
||||||
|
Ok(_) => (),
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => (),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = UnixListener::bind(&path)?;
|
||||||
|
Ok((listener, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn accept(listener: &mut UnixListener, _addr: &PathBuf) -> Result<(UnixStream, u32), HandlerError> {
|
||||||
|
let (stream, _addr) = listener.accept().await?;
|
||||||
|
let pid = stream.peer_cred()?
|
||||||
|
.pid()
|
||||||
|
.ok_or(ClientInfoError::PidNotFound)?
|
||||||
|
as u32;
|
||||||
|
|
||||||
|
Ok((stream, pid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod platform {
|
||||||
|
use std::os::windows::io::AsRawHandle;
|
||||||
|
use tokio::net::windows::named_pipe::{
|
||||||
|
NamedPipeServer,
|
||||||
|
ServerOptions,
|
||||||
|
};
|
||||||
|
use windows::Win32::{
|
||||||
|
Foundation::HANDLE,
|
||||||
|
System::Pipes::GetNamedPipeClientProcessId,
|
||||||
|
};
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
pub type Stream = NamedPipeServer;
|
||||||
|
|
||||||
|
pub fn bind(sock_name: &str) -> std::io::Result<(String, NamedPipeServer)> {
|
||||||
|
let addr = creddy_cli::server_addr(sock_name);
|
||||||
|
let listener = ServerOptions::new()
|
||||||
|
.first_pipe_instance(true)
|
||||||
|
.create(&addr)?;
|
||||||
|
Ok((listener, addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn accept(listener: &mut NamedPipeServer, addr: &String) -> Result<(NamedPipeServer, u32), HandlerError> {
|
||||||
|
// connect() just waits for a client to connect, it doesn't return anything
|
||||||
|
listener.connect().await?;
|
||||||
|
|
||||||
|
// unlike Unix sockets, a Windows NamedPipeServer *becomes* the open stream
|
||||||
|
// once a client connects. If we want to keep listening, we have to construct
|
||||||
|
// a new server and swap it in.
|
||||||
|
let new_listener = ServerOptions::new().create(addr)?;
|
||||||
|
let stream = std::mem::replace(listener, new_listener);
|
||||||
|
|
||||||
|
let raw_handle = stream.as_raw_handle();
|
||||||
|
let mut pid = 0u32;
|
||||||
|
let handle = HANDLE(raw_handle as _);
|
||||||
|
unsafe { GetNamedPipeClientProcessId(handle, &mut pid as *mut u32)? };
|
||||||
|
Ok((stream, pid))
|
||||||
|
}
|
||||||
|
}
|
395
src-tauri/src/state.rs
Normal file
395
src-tauri/src/state.rs
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::collections::hash_map::Entry;
|
||||||
|
use std::time::Duration;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
use tokio::{
|
||||||
|
sync::{RwLock, RwLockReadGuard},
|
||||||
|
sync::oneshot::{self, Sender},
|
||||||
|
};
|
||||||
|
use ssh_agent_lib::proto::message::Identity;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use sqlx::types::Uuid;
|
||||||
|
use tauri::{
|
||||||
|
Manager,
|
||||||
|
async_runtime as rt,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app;
|
||||||
|
use crate::credentials::{
|
||||||
|
AppSession,
|
||||||
|
AwsSessionCredential,
|
||||||
|
SshKey,
|
||||||
|
};
|
||||||
|
use crate::{config, config::AppConfig};
|
||||||
|
use crate::credentials::{
|
||||||
|
AwsBaseCredential,
|
||||||
|
Credential,
|
||||||
|
CredentialRecord,
|
||||||
|
PersistentCredential
|
||||||
|
};
|
||||||
|
use crate::ipc::{self, RequestResponse};
|
||||||
|
use crate::errors::*;
|
||||||
|
use crate::shortcuts;
|
||||||
|
|
||||||
|
|
||||||
|
#[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")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: RwLock<AppConfig>,
|
||||||
|
pub app_session: RwLock<AppSession>,
|
||||||
|
// session cache is keyed on id rather than name because names can change
|
||||||
|
pub aws_sessions: RwLock<HashMap<Uuid, AwsSessionCredential>>,
|
||||||
|
pub last_activity: RwLock<OffsetDateTime>,
|
||||||
|
pub request_count: RwLock<u64>,
|
||||||
|
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(
|
||||||
|
config: AppConfig,
|
||||||
|
app_session: AppSession,
|
||||||
|
pool: SqlitePool,
|
||||||
|
setup_errors: Vec<String>,
|
||||||
|
desktop_is_gnome: bool,
|
||||||
|
) -> AppState {
|
||||||
|
AppState {
|
||||||
|
config: RwLock::new(config),
|
||||||
|
app_session: RwLock::new(app_session),
|
||||||
|
aws_sessions: RwLock::new(HashMap::new()),
|
||||||
|
last_activity: RwLock::new(OffsetDateTime::now_utc()),
|
||||||
|
request_count: RwLock::new(0),
|
||||||
|
waiting_requests: RwLock::new(HashMap::new()),
|
||||||
|
pending_terminal_request: RwLock::new(false),
|
||||||
|
setup_errors,
|
||||||
|
desktop_is_gnome,
|
||||||
|
pool,
|
||||||
|
visibility: RwLock::new(Visibility::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_credential(&self, record: CredentialRecord) -> Result<(), SaveCredentialsError> {
|
||||||
|
let session = self.app_session.read().await;
|
||||||
|
let crypto = session.try_get_crypto()?;
|
||||||
|
record.save(crypto, &self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_credential(&self, id: &Uuid) -> Result<(), SaveCredentialsError> {
|
||||||
|
sqlx::query!("DELETE FROM credentials WHERE id = ?", id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_credentials(&self) -> Result<Vec<CredentialRecord>, GetCredentialsError> {
|
||||||
|
let session = self.app_session.read().await;
|
||||||
|
let crypto = session.try_get_crypto()?;
|
||||||
|
let list = CredentialRecord::list(crypto, &self.pool).await?;
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_ssh_identities(&self) -> Result<Vec<Identity>, GetCredentialsError> {
|
||||||
|
Ok(SshKey::list_identities(&self.pool).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_passphrase(&self, passphrase: &str) -> Result<(), SaveCredentialsError> {
|
||||||
|
let mut cur_session = self.app_session.write().await;
|
||||||
|
if let AppSession::Locked {..} = *cur_session {
|
||||||
|
return Err(SaveCredentialsError::Locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_session = AppSession::new(passphrase)?;
|
||||||
|
if let AppSession::Unlocked {salt: _, ref crypto} = *cur_session {
|
||||||
|
CredentialRecord::rekey(
|
||||||
|
crypto,
|
||||||
|
new_session.try_get_crypto().expect("AppSession::new() should always return Unlocked"),
|
||||||
|
&self.pool,
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
new_session.save(&self.pool).await?;
|
||||||
|
*cur_session = new_session;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
|
||||||
|
let mut live_config = self.config.write().await;
|
||||||
|
|
||||||
|
// update autostart if necessary
|
||||||
|
if new_config.start_on_login != live_config.start_on_login {
|
||||||
|
config::set_auto_launch(new_config.start_on_login)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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().await;
|
||||||
|
*c += 1;
|
||||||
|
c
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut waiting_requests = self.waiting_requests.write().await;
|
||||||
|
waiting_requests.insert(*count, sender); // `count` is the request id
|
||||||
|
*count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unregister_request(&self, id: u64) {
|
||||||
|
let mut waiting_requests = self.waiting_requests.write().await;
|
||||||
|
waiting_requests.remove(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
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 mut session = self.app_session.write().await;
|
||||||
|
session.unlock(passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lock(&self) -> Result<(), LockError> {
|
||||||
|
let mut session = self.app_session.write().await;
|
||||||
|
match *session {
|
||||||
|
AppSession::Empty => Err(LockError::NotUnlocked),
|
||||||
|
AppSession::Locked{..} => Err(LockError::NotUnlocked),
|
||||||
|
AppSession::Unlocked{..} => {
|
||||||
|
*session = AppSession::load(&self.pool).await?;
|
||||||
|
|
||||||
|
let app_handle = app::APP.get().unwrap();
|
||||||
|
app_handle.emit("locked", None::<usize>)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reset_session(&self) -> Result<(), SaveCredentialsError> {
|
||||||
|
let mut session = self.app_session.write().await;
|
||||||
|
session.reset(&self.pool).await?;
|
||||||
|
sqlx::query!("DELETE FROM credentials").execute(&self.pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_aws_base(&self, name: Option<String>) -> Result<AwsBaseCredential, GetCredentialsError> {
|
||||||
|
let app_session = self.app_session.read().await;
|
||||||
|
let crypto = app_session.try_get_crypto()?;
|
||||||
|
let creds = match name {
|
||||||
|
Some(n) => AwsBaseCredential::load_by_name(&n, crypto, &self.pool).await?,
|
||||||
|
None => AwsBaseCredential::load_default(crypto, &self.pool).await?,
|
||||||
|
};
|
||||||
|
Ok(creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_aws_session(&self, name: Option<String>) -> Result<RwLockReadGuard<'_, AwsSessionCredential>, GetCredentialsError> {
|
||||||
|
let app_session = self.app_session.read().await;
|
||||||
|
let crypto = app_session.try_get_crypto()?;
|
||||||
|
let record = match name {
|
||||||
|
Some(n) => CredentialRecord::load_by_name(&n, crypto, &self.pool).await?,
|
||||||
|
None => CredentialRecord::load_default("aws", crypto, &self.pool).await?,
|
||||||
|
};
|
||||||
|
let base = match &record.credential {
|
||||||
|
Credential::AwsBase(b) => Ok(b),
|
||||||
|
_ => Err(LoadCredentialsError::NoCredentials)
|
||||||
|
}?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut aws_sessions = self.aws_sessions.write().await;
|
||||||
|
match aws_sessions.entry(record.id) {
|
||||||
|
Entry::Vacant(e) => {
|
||||||
|
e.insert(AwsSessionCredential::from_base(&base).await?);
|
||||||
|
},
|
||||||
|
Entry::Occupied(mut e) if e.get().is_expired() => {
|
||||||
|
*(e.get_mut()) = AwsSessionCredential::from_base(&base).await?;
|
||||||
|
},
|
||||||
|
_ => ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we know the unwrap is safe, because we just made sure of it
|
||||||
|
let s = RwLockReadGuard::map(self.aws_sessions.read().await, |map| map.get(&record.id).unwrap());
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ssh_name_from_pubkey(&self, pubkey: &[u8]) -> Result<String, GetCredentialsError> {
|
||||||
|
let k = SshKey::name_from_pubkey(pubkey, &self.pool).await?;
|
||||||
|
Ok(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sshkey_by_name(&self, name: &str) -> Result<SshKey, GetCredentialsError> {
|
||||||
|
let app_session = self.app_session.read().await;
|
||||||
|
let crypto = app_session.try_get_crypto()?;
|
||||||
|
let k = SshKey::load_by_name(name, crypto, &self.pool).await?;
|
||||||
|
Ok(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
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_locked().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_locked(&self) -> bool {
|
||||||
|
let session = self.app_session.read().await;
|
||||||
|
matches!(*session, AppSession::Locked {..})
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::credentials::Crypto;
|
||||||
|
use sqlx::types::Uuid;
|
||||||
|
|
||||||
|
|
||||||
|
fn test_state(pool: SqlitePool) -> AppState {
|
||||||
|
let salt = [0u8; 32];
|
||||||
|
let crypto = Crypto::fixed();
|
||||||
|
AppState::new(
|
||||||
|
AppConfig::default(),
|
||||||
|
AppSession::Unlocked { salt, crypto },
|
||||||
|
pool,
|
||||||
|
vec![],
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("./credentials/fixtures/aws_credentials.sql"))]
|
||||||
|
fn test_delete_credential(pool: SqlitePool) {
|
||||||
|
let state = test_state(pool);
|
||||||
|
let id = Uuid::try_parse("00000000-0000-0000-0000-000000000000").unwrap();
|
||||||
|
state.delete_credential(&id).await.unwrap();
|
||||||
|
|
||||||
|
// ensure delete-cascade went through correctly
|
||||||
|
let res = AwsBaseCredential::load(&id, &Crypto::fixed(), &state.pool).await;
|
||||||
|
assert!(matches!(res, Err(LoadCredentialsError::NoCredentials)));
|
||||||
|
}
|
||||||
|
}
|
@ -1,31 +0,0 @@
|
|||||||
use sodiumoxide::crypto::{pwhash, secretbox};
|
|
||||||
|
|
||||||
|
|
||||||
pub fn save(data: &str, passphrase: &str) {
|
|
||||||
let salt = pwhash::Salt([0; 32]); // yes yes, just for now
|
|
||||||
let mut kbuf = [0; secretbox::KEYBYTES];
|
|
||||||
pwhash::derive_key_interactive(&mut kbuf, passphrase.as_bytes(), &salt)
|
|
||||||
.expect("Couldn't compute password hash. Are you out of memory?");
|
|
||||||
let key = secretbox::Key(kbuf);
|
|
||||||
let nonce = secretbox::Nonce([0; 24]); // we don't care about e.g. replay attacks so this might be safe?
|
|
||||||
let encrypted = secretbox::seal(data.as_bytes(), &nonce, &key);
|
|
||||||
|
|
||||||
//todo: store in a database, along with salt, nonce, and hash parameters
|
|
||||||
std::fs::write("credentials.enc", &encrypted).expect("Failed to write file.");
|
|
||||||
|
|
||||||
//todo: key is automatically zeroed, but we should use 'zeroize' or something to zero out passphrase and data
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
pub fn load(passphrase: &str) -> String {
|
|
||||||
let salt = pwhash::Salt([0; 32]);
|
|
||||||
let mut kbuf = [0; secretbox::KEYBYTES];
|
|
||||||
pwhash::derive_key_interactive(&mut kbuf, passphrase.as_bytes(), &salt)
|
|
||||||
.expect("Couldn't compute password hash. Are you out of memory?");
|
|
||||||
let key = secretbox::Key(kbuf);
|
|
||||||
let nonce = secretbox::Nonce([0; 24]);
|
|
||||||
|
|
||||||
let encrypted = std::fs::read("credentials.enc").expect("Failed to read file.");
|
|
||||||
let decrypted = secretbox::open(&encrypted, &nonce, &key).expect("Failed to decrypt.");
|
|
||||||
String::from_utf8(decrypted).expect("Invalid utf-8")
|
|
||||||
}
|
|
86
src-tauri/src/terminal.rs
Normal file
86
src-tauri/src/terminal.rs
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
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 res = do_launch(app, use_base).await;
|
||||||
|
|
||||||
|
state.unregister_terminal_request().await;
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// this handles most of the work, the outer function is just to ensure we properly
|
||||||
|
// unregister the request if there's an error
|
||||||
|
async fn do_launch(app: &AppHandle, use_base: bool) -> Result<(), LaunchTerminalError> {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
|
||||||
|
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, wait for credentials from frontend
|
||||||
|
if state.is_locked().await {
|
||||||
|
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("unlocked", move |_| {
|
||||||
|
let _ = tx.send(());
|
||||||
|
});
|
||||||
|
|
||||||
|
let timeout = Duration::from_secs(60);
|
||||||
|
tokio::select! {
|
||||||
|
// if the frontend is unlocked within 60 seconds, release visibility lock and proceed
|
||||||
|
_ = rx => lease.release(),
|
||||||
|
// otherwise, dump this request, but return Ok so we don't get an error popup
|
||||||
|
_ = sleep(timeout) => {
|
||||||
|
eprintln!("WARNING: Request to launch terminal timed out after 60 seconds.");
|
||||||
|
return Ok(());
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if use_base {
|
||||||
|
let base_creds = state.get_aws_base(None).await?;
|
||||||
|
cmd.env("AWS_ACCESS_KEY_ID", &base_creds.access_key_id);
|
||||||
|
cmd.env("AWS_SECRET_ACCESS_KEY", &base_creds.secret_access_key);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let session_creds = state.get_aws_session(None).await?;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
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,24 +3,14 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"beforeBuildCommand": "npm run build",
|
"beforeBuildCommand": "npm run build",
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"devPath": "http://localhost:5173",
|
"frontendDist": "../dist",
|
||||||
"distDir": "../dist"
|
"devUrl": "http://localhost:5173"
|
||||||
},
|
|
||||||
"package": {
|
|
||||||
"productName": "creddy",
|
|
||||||
"version": "0.1.0"
|
|
||||||
},
|
|
||||||
"tauri": {
|
|
||||||
"allowlist": {
|
|
||||||
"all": true
|
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"category": "DeveloperTool",
|
"category": "DeveloperTool",
|
||||||
"copyright": "",
|
"copyright": "",
|
||||||
"deb": {
|
"targets": "all",
|
||||||
"depends": []
|
|
||||||
},
|
|
||||||
"externalBin": [],
|
"externalBin": [],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
@ -29,7 +19,20 @@
|
|||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"identifier": "com.tauri.dev",
|
"windows": {
|
||||||
|
"certificateThumbprint": null,
|
||||||
|
"digestAlgorithm": "sha256",
|
||||||
|
"timestampUrl": "",
|
||||||
|
"wix": {
|
||||||
|
"fragmentPaths": [
|
||||||
|
"conf/cli.wxs"
|
||||||
|
],
|
||||||
|
"componentRefs": [
|
||||||
|
"CliBinary",
|
||||||
|
"AddToPath"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"longDescription": "",
|
"longDescription": "",
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"entitlements": null,
|
"entitlements": null,
|
||||||
@ -40,27 +43,46 @@
|
|||||||
},
|
},
|
||||||
"resources": [],
|
"resources": [],
|
||||||
"shortDescription": "",
|
"shortDescription": "",
|
||||||
"targets": "all",
|
"linux": {
|
||||||
"windows": {
|
"deb": {
|
||||||
"certificateThumbprint": null,
|
"depends": []
|
||||||
"digestAlgorithm": "sha256",
|
}
|
||||||
"timestampUrl": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"security": {
|
"productName": "creddy",
|
||||||
"csp": null
|
"version": "0.5.4",
|
||||||
},
|
"identifier": "creddy",
|
||||||
"updater": {
|
"plugins": {},
|
||||||
"active": false
|
"app": {
|
||||||
},
|
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"height": 600,
|
"height": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
|
"label": "main",
|
||||||
"title": "Creddy",
|
"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,13 +1,72 @@
|
|||||||
<script>
|
<script>
|
||||||
import { emit, listen } from '@tauri-apps/api/event';
|
import { onMount } from 'svelte';
|
||||||
import Home from './views/Home.svelte';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
|
|
||||||
|
import { appState, acceptRequest, cleanupRequest } from './lib/state.js';
|
||||||
|
import { views, currentView, navigate } from './lib/routing.js';
|
||||||
|
|
||||||
import Approve from './views/Approve.svelte';
|
import Approve from './views/Approve.svelte';
|
||||||
|
import CreatePassphrase from './views/CreatePassphrase.svelte';
|
||||||
|
import Unlock from './views/Unlock.svelte';
|
||||||
|
|
||||||
let activeComponent = Home;
|
// set up app state
|
||||||
|
invoke('get_config').then(config => $appState.config = config);
|
||||||
|
invoke('get_session_status').then(status => $appState.sessionStatus = status);
|
||||||
|
getVersion().then(version => $appState.appVersion = version);
|
||||||
|
invoke('get_setup_errors')
|
||||||
|
.then(errs => {
|
||||||
|
$appState.setupErrors = errs.map(e => ({msg: e, show: true}));
|
||||||
|
});
|
||||||
|
|
||||||
listen('credentials-request', (event) => {
|
|
||||||
activeComponent = Approve;
|
// set up event handlers
|
||||||
})
|
listen('credential-request', (tauriEvent) => {
|
||||||
|
$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('locked', () => {
|
||||||
|
$appState.sessionStatus = 'locked';
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// set up navigation
|
||||||
|
$views = import.meta.glob('./views/*.svelte', {eager: true});
|
||||||
|
navigate('Home');
|
||||||
|
|
||||||
|
|
||||||
|
// ready to rock and roll
|
||||||
|
acceptRequest();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:component this={activeComponent} />
|
|
||||||
|
<svelte:window
|
||||||
|
on:click={() => invoke('signal_activity')}
|
||||||
|
on:keydown={() => invoke('signal_activity')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
{#if $appState.sessionStatus === 'empty'}
|
||||||
|
<!-- Empty state (no passphrase) takes precedence over everything -->
|
||||||
|
<CreatePassphrase />
|
||||||
|
{:else if $appState.currentRequest !== null}
|
||||||
|
<!-- if a request is pending, show approval flow (will include unlock if necessary) -->
|
||||||
|
<Approve />
|
||||||
|
{:else if $appState.sessionStatus === 'locked'}
|
||||||
|
<!-- if session is locked and no request is pending, show unlock screen -->
|
||||||
|
<Unlock />
|
||||||
|
{:else}
|
||||||
|
<!-- normal operation -->
|
||||||
|
<svelte:component this="{$currentView}" />
|
||||||
|
{/if}
|
||||||
|
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 |
17
src/lib/errors.js
Normal file
17
src/lib/errors.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export function getRootCause(error) {
|
||||||
|
if (error.source) {
|
||||||
|
return getRootCause(error.source);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function fullMessage(error) {
|
||||||
|
let msg = error?.msg ? error.msg : error;
|
||||||
|
if (error.source) {
|
||||||
|
msg = `${msg}: ${fullMessage(error.source)}`;
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
@ -9,10 +9,14 @@ export default function() {
|
|||||||
|
|
||||||
resolvers: [],
|
resolvers: [],
|
||||||
|
|
||||||
|
size() {
|
||||||
|
return this.items.length;
|
||||||
|
},
|
||||||
|
|
||||||
put(item) {
|
put(item) {
|
||||||
this.items.push(item);
|
this.items.push(item);
|
||||||
if (this.resolvers.length > 0) {
|
|
||||||
let resolver = this.resolvers.shift();
|
let resolver = this.resolvers.shift();
|
||||||
|
if (resolver) {
|
||||||
resolver();
|
resolver();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -26,5 +30,15 @@ export default function() {
|
|||||||
|
|
||||||
return this.items.shift();
|
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(),
|
||||||
|
sessionStatus: '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() {
|
||||||
|
currentView.set(get(previousView));
|
||||||
|
previousView.set(null);
|
||||||
|
appState.update($appState => {
|
||||||
|
$appState.currentRequest = null;
|
||||||
|
return $appState;
|
||||||
|
});
|
||||||
|
acceptRequest();
|
||||||
|
}
|
@ -1,3 +1,12 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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>
|
93
src/ui/ErrorAlert.svelte
Normal file
93
src/ui/ErrorAlert.svelte
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { fullMessage } from '../lib/errors.js';
|
||||||
|
|
||||||
|
|
||||||
|
let extraClasses = "";
|
||||||
|
export {extraClasses as class};
|
||||||
|
export let slideDuration = 150;
|
||||||
|
let animationClass = "";
|
||||||
|
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
function shake() {
|
||||||
|
animationClass = 'shake';
|
||||||
|
window.setTimeout(() => animationClass = "", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(fallible) {
|
||||||
|
try {
|
||||||
|
const ret = await Promise.resolve(fallible());
|
||||||
|
error = null;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
if (error) shake();
|
||||||
|
error = e;
|
||||||
|
// re-throw so it can be caught by the caller if necessary
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is a method rather than a prop so that we can re-shake every time
|
||||||
|
// the error occurs, even if the error message doesn't change
|
||||||
|
export function setError(e) {
|
||||||
|
if (error) shake();
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div transition:slide="{{duration: slideDuration}}" class="alert alert-error shadow-lg {animationClass} {extraClasses}">
|
||||||
|
<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 {error}>{fullMessage(error)}</slot>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if $$slots.buttons}
|
||||||
|
<div>
|
||||||
|
<slot name="buttons"></slot>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
53
src/ui/FileInput.svelte
Normal file
53
src/ui/FileInput.svelte
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<script>
|
||||||
|
// import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { sep } from '@tauri-apps/api/path';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
|
||||||
|
|
||||||
|
export let value = {};
|
||||||
|
export let params = {};
|
||||||
|
let displayValue = value?.name || '';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
async function chooseFile() {
|
||||||
|
let file = await open(params);
|
||||||
|
if (file) {
|
||||||
|
value = file;
|
||||||
|
displayValue = file.name;
|
||||||
|
dispatch('update', value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(evt) {
|
||||||
|
const segments = evt.target.value.split(sep());
|
||||||
|
const name = segments[segments.length - 1];
|
||||||
|
value = {name, path: evt.target.value};
|
||||||
|
}
|
||||||
|
|
||||||
|
// some day, figure out drag-and-drop
|
||||||
|
// let drag = null;
|
||||||
|
// listen('tauri://drag', e => drag = e);
|
||||||
|
// listen('tauri://drop', e => console.log(e));
|
||||||
|
// listen('tauri://drag-cancelled', e => console.log(e));
|
||||||
|
// listen('tauri://drop-over', e => console.log(e));
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="relative flex join has-[:focus]:outline outline-2 outline-offset-2 outline-base-content/20">
|
||||||
|
<button type="button" class="btn btn-neutral join-item" on:click={chooseFile}>
|
||||||
|
Choose file
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="join-item grow input input-bordered border-l-0 bg-transparent focus:outline-none"
|
||||||
|
value={displayValue}
|
||||||
|
on:input={handleInput}
|
||||||
|
on:change={() => dispatch('update', value)}
|
||||||
|
on:focus on:blur
|
||||||
|
>
|
||||||
|
</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};
|
||||||
|
|
||||||
|
$: 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>
|
44
src/ui/Link.svelte
Normal file
44
src/ui/Link.svelte
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<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();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
52
src/ui/PassphraseInput.svelte
Normal file
52
src/ui/PassphraseInput.svelte
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<script>
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
|
||||||
|
export let value = '';
|
||||||
|
export let placeholder = '';
|
||||||
|
export let autofocus = false;
|
||||||
|
let classes = '';
|
||||||
|
export {classes as class};
|
||||||
|
|
||||||
|
let show = false;
|
||||||
|
let input;
|
||||||
|
|
||||||
|
export function focus() {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
button {
|
||||||
|
border: 1px solid oklch(var(--bc) / 0.2);
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="join w-full has-[:focus]:outline outline-2 outline-offset-2 outline-base-content/20">
|
||||||
|
<input
|
||||||
|
bind:this={input}
|
||||||
|
type={show ? 'text' : 'password'}
|
||||||
|
{value} {placeholder} {autofocus}
|
||||||
|
on:input={e => value = e.target.value}
|
||||||
|
on:input on:change on:focus on:blur
|
||||||
|
class="input input-bordered flex-grow join-item placeholder:text-gray-500 focus:outline-none {classes}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost join-item swap swap-rotate"
|
||||||
|
class:swap-active={show}
|
||||||
|
on:click={() => show = !show}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="eye"
|
||||||
|
class="w-5 h-5 swap-off"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name="eye-slash"
|
||||||
|
class="w-5 h-5 swap-on"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
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/arrow-right-start-on-rectangle.svelte
Normal file
8
src/ui/icons/arrow-right-start-on-rectangle.svelte
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script>
|
||||||
|
let classes = '';
|
||||||
|
export {classes as class}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class={classes}>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
|
||||||
|
</svg>
|
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/command-line.svelte
Normal file
8
src/ui/icons/command-line.svelte
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script>
|
||||||
|
let classes = '';
|
||||||
|
export {classes as class}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class={classes}>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||||
|
</svg>
|
9
src/ui/icons/eye-slash.svelte
Normal file
9
src/ui/icons/eye-slash.svelte
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script>
|
||||||
|
let classes = "";
|
||||||
|
export {classes as class};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class={classes}>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
</svg>
|
||||||
|
|
9
src/ui/icons/eye.svelte
Normal file
9
src/ui/icons/eye.svelte
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script>
|
||||||
|
let classes = "";
|
||||||
|
export {classes as class};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class={classes}>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 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/key.svelte
Normal file
8
src/ui/icons/key.svelte
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script>
|
||||||
|
let classes = '';
|
||||||
|
export {classes as class}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class={classes}>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z" />
|
||||||
|
</svg>
|
8
src/ui/icons/pencil.svelte
Normal file
8
src/ui/icons/pencil.svelte
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script>
|
||||||
|
let classes = '';
|
||||||
|
export {classes as class};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class={classes}>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||||
|
</svg>
|
9
src/ui/icons/plus-circle-mini.svelte
Normal file
9
src/ui/icons/plus-circle-mini.svelte
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script>
|
||||||
|
let classes = "";
|
||||||
|
export {classes as class};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class={classes}>
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 0 0-1.5 0v2.5h-2.5a.75.75 0 0 0 0 1.5h2.5v2.5a.75.75 0 0 0 1.5 0v-2.5h2.5a.75.75 0 0 0 0-1.5h-2.5v-2.5Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
|
8
src/ui/icons/shield-check.svelte
Normal file
8
src/ui/icons/shield-check.svelte
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script>
|
||||||
|
let classes = '';
|
||||||
|
export {classes as class}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class={classes}>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
|
||||||
|
</svg>
|
8
src/ui/icons/trash.svelte
Normal file
8
src/ui/icons/trash.svelte
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script>
|
||||||
|
let classes = '';
|
||||||
|
export {classes as class};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class={classes}>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||||
|
</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>
|
35
src/ui/settings/FileSetting.svelte
Normal file
35
src/ui/settings/FileSetting.svelte
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<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();
|
||||||
|
|
||||||
|
async function pickFile() {
|
||||||
|
let file = await open();
|
||||||
|
if (file) {
|
||||||
|
value = file.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
on:click={pickFile}
|
||||||
|
>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>
|
21
src/ui/settings/Setting.svelte
Normal file
21
src/ui/settings/Setting.svelte
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script>
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
|
export let title;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap justify-between gap-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';
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user