Compare commits

...

148 Commits
dev ... master

Author SHA1 Message Date
295698e62f focus unlock input when window is focused 2024-09-18 09:29:14 -04:00
3b61aa924a test CLI credentials against main app 2024-07-21 06:38:25 -04:00
02ba19d709 switch to clap derive instead of builder 2024-07-15 14:54:25 -04:00
55801384eb split into workspace so CLI can be a standalone crate 2024-07-15 10:34:51 -04:00
27c2f467c4 fix cli invocations in gui entrypoint 2024-07-14 20:51:49 -04:00
cab5ec40cc make server_addr configurable for client 2024-07-12 14:50:36 -04:00
5cf848f7fe clean up warnings 2024-07-11 06:04:56 -04:00
a32e36be7e fix RSA key signatures 2024-07-04 21:57:27 -04:00
10231df860 add alternate entry mode for ssh keys 2024-07-03 16:28:31 -04:00
ae93a57aab v0.5.0 2024-07-03 14:56:35 -04:00
9fd355b68e finish SSH key support 2024-07-03 14:54:10 -04:00
00089d7efb fix compiler warnings 2024-07-03 06:47:25 -04:00
0124f77f7b initial working implementation of ssh agent 2024-07-03 06:33:58 -04:00
6711ce2c43 finish manage ui for ssh keys 2024-07-02 09:57:02 -04:00
a3a11897c2 persistence for ssh keys 2024-07-01 13:27:20 -04:00
5e6542d08e initial ssh key model and creation ui 2024-07-01 06:38:46 -04:00
f311fde74e fix permissions errors and terminal launching 2024-06-29 20:42:51 -04:00
acc5c71bfa rework error alerts 2024-06-28 20:35:18 -04:00
504c0b4156 add passphrase reset 2024-06-28 11:19:52 -04:00
bf0a2ca72d finish manage-credentials page and rework home screen 2024-06-28 06:25:55 -04:00
bb980c5eef continue working on default credentials 2024-06-26 22:24:44 -04:00
ce7d75f15a almost finish refactoring PersistentCredential trait 2024-06-26 15:01:07 -04:00
37b44ddb2e start refactoring for default credentials 2024-06-26 11:10:50 -04:00
8c668e51a6 still in progress 2024-06-25 15:19:29 -04:00
9928996fab get backend running 2024-06-19 05:10:55 -04:00
d0a2532c27 start working on generalizing credential logic 2024-06-16 07:08:10 -04:00
0491cb5790 fix error popup on startup 2024-06-03 01:33:35 -04:00
816bd7db00 upgrade to tauri 2.0 beta 2024-06-02 17:20:37 -04:00
b165965289 update todo 2024-05-08 11:37:07 -04:00
86896d68c2 update Cargo.lock 2024-02-24 19:42:39 -08:00
64a2927b94 return to Approve screen after cancelling unlock during request approval 2024-02-07 13:03:12 -08:00
87617a0726 add ui for idle timeout 2024-02-06 20:27:51 -08:00
141334f7e2 add idle timeout and version on settings screen 2024-01-31 13:14:08 -08:00
69f6a39396 change tray menu text when toggling visibility 2024-01-26 21:03:45 -08:00
70e23c7e20 add version to BaseCredentials 2024-01-23 10:58:39 -08:00
1df849442e cancel approval flow on frontend when request is abandoned by client 2024-01-21 13:46:39 -08:00
7fdb336c79 rework approval buttons and add hotkey for base approval 2024-01-20 11:06:27 -08:00
46b8d810c5 allow user to choose whether to send base credentials at approval screen 2024-01-10 17:10:14 -08:00
dd40eb379e update dependencies 2024-01-10 16:31:10 -08:00
Joseph Montanaro
13545ac725 v0.4.1 2023-11-09 14:25:20 -08:00
Joseph Montanaro
040a01536a work around Gnome focus-stealing prevention 2023-11-09 14:24:44 -08:00
Joseph Montanaro
4e2a90b15b remove old client info code 2023-11-09 13:46:15 -08:00
e0d919ed4a fix Windows pipe server 2023-10-09 16:29:41 -07:00
3f4efc5f8f bump version to 0.4.0 2023-10-09 10:06:28 -07:00
4881b90b0b merge branch 'pipe' 2023-10-09 08:54:26 -07:00
1b749a857c disable hotkeys if initial registration fails 2023-10-09 08:50:31 -07:00
2079f99d04 bump version to 0.3.4 2023-10-08 22:53:22 -07:00
5e0ffc1155 use save dialog for settings instead of autosaving 2023-10-08 22:06:30 -07:00
d4fa8966b2 add unix listener, split win/unix into separate submodules 2023-09-23 11:10:54 -07:00
a293d8f92c ignore .env so it can be system-specific 2023-09-22 12:43:44 -07:00
Joseph Montanaro
367a140e2a disable keyboard shortcuts if registration fails 2023-09-21 14:55:02 -07:00
4b06dce7f4 keep working on cli shortcuts, unify visibility management 2023-09-21 10:44:35 -07:00
47a3e1cfef start work on invoking shortcuts from CLI 2023-09-18 20:13:56 -07:00
1047818fdc basic implementation of named-pipe server 2023-09-18 20:13:29 -07:00
Joseph Montanaro
3d093a3a45 show version in cli 2023-09-14 15:22:38 -07:00
Joseph Montanaro
992d2a4d06 show non-fatal setup errors on home screen instead of in popup 2023-09-14 15:13:19 -07:00
Joseph Montanaro
12f0f187a6 update cargo and npm 2023-09-14 12:49:45 -07:00
997e8b419f handle setup errors more gracefully 2023-09-13 11:06:40 -07:00
1d9132de3b make hotkey configuration more timing tolerant 2023-09-12 21:46:25 -07:00
Joseph Montanaro
e1c2618dc8 bump version 2023-09-12 15:31:03 -07:00
Joseph Montanaro
a7df7adc8e Ignore keyup events for modifier keys 2023-09-12 15:27:15 -07:00
Joseph Montanaro
03d164c9d3 Inherit rehide flag from existing request if present 2023-09-12 14:10:57 -07:00
f522674a1c don't remove request from state until after re-hiding window 2023-09-12 11:47:33 -07:00
51fcccafa2 fix os type calculation and bump version 2023-09-11 16:18:05 -07:00
e3913ab4c9 add todo list 2023-09-11 16:11:06 -07:00
c16f21bba3 Merge branch 'terminal' 2023-09-11 16:10:58 -07:00
61d9acc7c6 request unlock/credentials when terminal is launched from locked/empty state 2023-09-11 16:00:58 -07:00
8d7b01629d make keybinds configurable 2023-09-10 14:04:09 -07:00
5685948608 add hotkeys to show window and launch terminal 2023-09-09 07:29:57 -07:00
c98a065587 make terminal emulator configurable 2023-09-09 06:30:19 -07:00
e46c3d2b4d tweak home screen 2023-09-05 06:12:26 -07:00
fa228acc3a use svg animation for spinner 2023-08-06 21:25:24 -07:00
e7e0f9d33e very basic launch button 2023-08-03 22:08:24 -07:00
a51b20add7 combine ExecError with LaunchError and use Session::try_get() instead of matching 2023-08-03 21:57:55 -07:00
Joseph Montanaro
890f715388 usable backend for terminal launch 2023-08-03 16:35:15 -07:00
89bc74e644 start working on terminal launcher 2023-08-02 19:57:37 -07:00
Joseph Montanaro
60c24e3ee4 don't autohide on first launch 2023-07-11 16:13:20 -07:00
Joseph Montanaro
486001b584 improve display of GetSessionError 2023-07-11 14:34:54 -07:00
Joseph Montanaro
52c949e396 v0.2.3 2023-07-11 10:35:56 -07:00
Joseph Montanaro
d7c5c2f37b update dependencies 2023-07-11 09:52:13 -07:00
Joseph Montanaro
ae5b8f31db remove spinner when unlock fails 2023-07-11 09:50:35 -07:00
c260e37e78 cryptography notes 2023-05-19 10:04:48 -07:00
7501253970 add separate binary for Windows CLI 2023-05-15 13:09:26 -07:00
5b9c711008 fix subprocess exec for unix 2023-05-09 09:47:11 -07:00
ddd1005067 switch crypto implementation and add spinner 2023-05-08 22:14:35 -07:00
e866a4a643 change location of dev db and bump version 2023-05-06 22:09:41 -07:00
94400ba7d5 get host addr/port from database when requesting credentials 2023-05-06 16:56:45 -07:00
616600687d add show/exec commands and refactor AppState 2023-05-06 12:01:56 -07:00
Joseph Montanaro
e8b8dc2976 cargo update 2023-05-02 15:24:46 -07:00
Joseph Montanaro
ddf865d0b4 switch to tokio RwLock instead of std 2023-05-02 15:24:35 -07:00
96bbc2dbc2 session renewal 2023-05-02 11:33:18 -07:00
161148d1f6 store base credentials as well as session credentials 2023-05-01 23:03:34 -07:00
760987f09b show approval errors in approval view 2023-05-01 16:53:24 -07:00
a75f34865e return to previous view after approval flow 2023-05-01 13:27:28 -07:00
886fcd9bb8 restrictive CSP and tauri allowlist 2023-05-01 09:05:46 -07:00
55775b6b05 move error dialog to trait 2023-04-30 14:10:21 -07:00
871dedf0a3 display setup errors 2023-04-30 10:52:46 -07:00
913148a75a minor tweaks 2023-04-29 10:01:45 -07:00
e746963052 change frontpage image and toast animation 2023-04-28 22:34:50 -07:00
b761d3b493 find data dir properly 2023-04-28 22:34:17 -07:00
c5dcc2e50a handle errors on config update 2023-04-28 14:33:23 -07:00
70d71ce14e restart listener when config changes 2023-04-28 14:33:04 -07:00
Joseph Montanaro
33a5600a30 prevent NumericSetting from accepting non-numeric inputs 2023-04-27 16:15:19 -07:00
Joseph Montanaro
741169d807 start on login 2023-04-27 14:24:08 -07:00
ebc00a5df6 confirm passphrase 2023-04-26 17:13:58 -07:00
c2cc007a81 display tweaks and approval page timing 2023-04-26 17:06:37 -07:00
4aab08e6f0 save settings to db 2023-04-26 15:49:08 -07:00
12d9d733a5 fix circular imports from routing 2023-04-26 13:05:51 -07:00
35271049dd settings page 2023-04-25 22:10:28 -07:00
6f9cd6b471 move app state to store 2023-04-25 08:49:00 -07:00
865b7fd5c4 add settings page 2023-04-24 22:18:55 -07:00
f35352eedd links, navs, and more 2023-04-24 22:16:25 -07:00
53580d7919 rework routing 2023-04-24 12:05:11 -07:00
049b81610d rewrite frontend with DaisyUI 2023-04-23 22:29:12 -07:00
Joseph Montanaro
fd60899f16 don't re-hide when a request comes in while showing approval screen 2023-04-21 11:18:20 -07:00
Joseph Montanaro
e0c4c849dc serializable structured errors 2022-12-29 16:40:48 -08:00
Joseph Montanaro
cb26201506 unproductive flailing 2022-12-28 15:48:25 -08:00
Joseph Montanaro
992e3c8db2 build improvements 2022-12-28 10:16:06 -08:00
Joseph Montanaro
4956b64371 only print incoming requests in debug mode 2022-12-28 08:54:08 -08:00
df6b362a31 return structured errors from commands (wip) 2022-12-23 11:34:17 -08:00
2943634248 button component 2022-12-22 21:50:09 -08:00
06f5a1af42 icon picker component 2022-12-22 19:53:14 -08:00
Joseph Montanaro
61d674199f store config in database, macro for state access 2022-12-22 16:36:32 -08:00
Joseph Montanaro
398916fe10 use different ports for dev and live modes 2022-12-21 16:22:24 -08:00
Joseph Montanaro
bf4c46238e move re-hide to main request handler 2022-12-21 16:04:12 -08:00
Joseph Montanaro
5ffa55c03c basic system tray functionality 2022-12-21 14:49:01 -08:00
Joseph Montanaro
50f0985f4f display client info and unlock errors to user 2022-12-21 13:43:37 -08:00
Joseph Montanaro
69475604c0 update npm deps 2022-12-21 11:02:19 -08:00
Joseph Montanaro
856b6f1e1b use thiserror for errors 2022-12-21 11:01:34 -08:00
Joseph Montanaro
414379b74e completely reorganize http server 2022-12-20 16:11:49 -08:00
Joseph Montanaro
80b92ebe69 generalize pid conversion 2022-12-20 13:01:44 -08:00
983d0e8639 autofocus passphrase field in unlock view 2022-12-19 20:45:26 -08:00
Joseph Montanaro
d77437cda8 ban list 2022-12-19 16:20:46 -08:00
Joseph Montanaro
3d5cbedae1 working basic flow 2022-12-19 15:26:44 -08:00
10fd1d6028 more work on establishing credentials 2022-12-14 14:52:16 -08:00
67705aa2d1 add credentials entry view 2022-12-13 21:50:34 -08:00
Joseph Montanaro
9055fa41aa switch to invokes instead of emits 2022-12-13 16:50:44 -08:00
Joseph Montanaro
48269855e5 start listing some specific threats 2022-12-13 10:58:52 -08:00
1e4e1c9a5f encrypt/decrypt and db interaction 2022-12-03 21:47:09 -08:00
196510e9a2 connect to db on startup 2022-12-02 22:59:13 -08:00
e423df8e51 add to migration 2022-11-30 21:45:43 -08:00
Joseph Montanaro
2cfde4d841 first migration 2022-11-30 16:27:59 -08:00
Joseph Montanaro
7d462645b4 update readme because why not 2022-11-30 16:04:14 -08:00
Joseph Montanaro
8c271281f7 add security document 2022-11-30 15:29:41 -08:00
Joseph Montanaro
234d9e0471 fix some things 2022-11-29 16:13:09 -08:00
Joseph Montanaro
397928b8f1 reorganize backend 2022-11-28 16:16:33 -08:00
c19b573b26 all is change; in change is all again made new 2022-11-27 22:03:15 -08:00
cee43342b9 event-based routing? 2022-11-23 17:11:44 -08:00
117 changed files with 17896 additions and 2839 deletions

9
.gitignore vendored
View File

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

View File

@ -1 +1,24 @@
## Creddy: Low-friction AWS credential manager ## Creddy: Low-friction AWS credential helper
_Security at the expense of usability comes at the expense of security._ - Avi Douglen
**Creddy** is an AWS credential helper that focuses on improving security without interrupting your workflow (much). It works by mimicking the AWS Instance Metadata Service and requesting your approval before granting any application access to your AWS credentials. Additionally, the credentials it hands out are short-lived session credentials rather than long-lived credentials, meaning that even if they are compromised, the damage that the attacker can do is limited.
### What was wrong with all the existing AWS credential managers?
Most other AWS credential managers that I have seen differ in two ways.
**First**, they require the user to be _proactive_ instead of _reactive_, i.e. you must remember "this command will require AWS credentials" and invoke it in some special way. By contrast, Creddy waits patiently in the background until an application requests credentials, then asks for your approval before proceeding. In most cases, this requires only a couple of keystrokes, after which your original operation continues as invoked. This completely prevents the frustrating workflow of:
```
$ aws do-something-interesting
...
...
Unable to locate credentials. You can configure credentials by running "aws configure".
# a deep sigh of the most profound resignation
$ with-aws-credentials aws do-something-interesting
```
**Second**, other credential managers are mostly backed by the system credential store. While this may sound like a good idea, it has a critical weakness: By default, on most systems, a user's credentials are accessible to _any process running as that user_. In other words, if your quick nodejs script happens to depend on a compromised module, congratulations: you have just given that module access to your AWS account.
By contrast, Creddy encrypts your main long-lived AWS credentials with a passphrase (using libsodium's `SecretBox`) and, importantly, _does not store that passphrase_. Although this means that you, the user, must re-enter the passphrase every time Creddy needs to generate a new session, this is normally only necessary about once per day. In my own opinion, this is a worthwhile tradeoff.

9
doc/cryptography.md Normal file
View File

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

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

View File

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

2425
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

5698
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +1,81 @@
[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
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
default = [ "custom-protocol" ] default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem # 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

View 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
View File

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

View File

@ -0,0 +1,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 }

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

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

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

View 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 {}

File diff suppressed because one or more lines are too long

View 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"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
-- Add migration script here
CREATE TABLE credentials (
access_key_id TEXT NOT NULL,
secret_key_enc BLOB NOT NULL,
salt BLOB NOT NULL,
nonce BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE config (
name TEXT UNIQUE NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE clients (
name TEXT,
path TEXT
);

View 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;

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

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

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

View 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 {{ [...] }}")
}
}

View 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'
);

View 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'
);

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

View File

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIASRxkrR1+nCDZiZN2d8Muvl+zW8undCJVCo+qVMAjkj hello world

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

View File

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

View File

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILuwWEaQin9IGcppvlDpRlj9b1HST/7O1nhWbUPh3Wvy hello world

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

View 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

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

View File

@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDEq85taUAJEuutUnczQB4w6/PclDO3nI40PXr74ZqfMJk0giV32YBzRrSNT7BgTQItqCblYkY15M4ZhRql0w39IAfembBK5MLwCCPf/DyN3mLwdIMcH4kDBnyD3M19nO6GQ8k8UpH2tQR/U2RqN8hAmJNP/eWIK13XaWzdxEIcOeKJR2jP1mUM5YXjWj9zmwFWUKpGmr3vxph+Vdr+x9QLQ4hlTtMgXRhSjYgZJ8QsviEMz29JqQYZrW5qy/F2jX7FL/nLhb5ge5QUlhVmKSAWh1FkwcHR+9TDVp1EJKfxnQQ6vN7lBXPfxPx/LCcYqnZSj6ImwN1VMNxwXDCQHhveiP5cw1yuWriCbR5/lw2+0KD36YM8/HMjofEyNSjVzDwArrmBZdZ3yvZL1pcpEyJk2XG1xJHQrq9TqtItA3VrLkN1RQLoRIgRfuu5YszfXfWWgsHpukctWrm4PbKGLn6jgOj9IN6TaMq8u8XJXCM6Ut5d/l6Ry1kBnQC1KccMQwU= hello world

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

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

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

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

View 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')

View File

@ -1,42 +0,0 @@
use std::fmt::{Display, Formatter};
use std::convert::From;
use std::str::Utf8Error;
// use tokio::sync::oneshot::error::RecvError;
// Represents errors encountered while handling an HTTP request
pub enum RequestError {
StreamIOError(std::io::Error),
InvalidUtf8,
MalformedHttpRequest,
RequestTooLarge,
}
impl From<tokio::io::Error> for RequestError {
fn from(e: std::io::Error) -> RequestError {
RequestError::StreamIOError(e)
}
}
impl From<Utf8Error> for RequestError {
fn from(_e: Utf8Error) -> RequestError {
RequestError::InvalidUtf8
}
}
// impl From<RecvError> for RequestError {
// fn from (_e: RecvError) -> RequestError {
// RequestError::
// }
// }
impl Display for RequestError {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
use RequestError::*;
match self {
StreamIOError(e) => write!(f, "Stream IO error: {e}"),
InvalidUtf8 => write!(f, "Could not decode UTF-8 from bytestream"),
MalformedHttpRequest => write!(f, "Maformed HTTP request"),
RequestTooLarge => write!(f, "HTTP request too large"),
}
}
}

View File

@ -1,99 +0,0 @@
use std::io;
use std::net::SocketAddrV4;
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tauri::{AppHandle, Manager};
mod errors;
use errors::RequestError;
pub async fn serve(addr: SocketAddrV4, app_handle: AppHandle) -> io::Result<()> {
let listener = TcpListener::bind(&addr).await?;
println!("Listening on {addr}");
loop {
let new_handle = app_handle.app_handle();
match listener.accept().await {
Ok((stream, _)) => {
tokio::spawn(async {
if let Err(e) = handle(stream, new_handle).await {
eprintln!("{e}");
}
});
},
Err(e) => {
println!("Error accepting connection: {e}");
}
}
}
}
// it doesn't really return a String, we just need to placate the compiler
async fn stall(stream: &mut TcpStream) -> Result<String, tokio::io::Error> {
let delay = std::time::Duration::from_secs(1);
loop {
tokio::time::sleep(delay).await;
stream.write(b"x").await?;
}
}
async fn handle(mut stream: TcpStream, app_handle: AppHandle) -> Result<(), RequestError> {
let mut buf = [0; 8192]; // it's what tokio's BufReader uses
let mut n = 0;
loop {
n += stream.read(&mut buf[n..]).await?;
if &buf[(n - 4)..n] == b"\r\n\r\n" {break;}
if n == buf.len() {return Err(RequestError::RequestTooLarge);}
}
println!("{}", std::str::from_utf8(&buf).unwrap());
stream.write(b"HTTP/1.0 200 OK\r\n").await?;
stream.write(b"Content-Type: application/json\r\n").await?;
stream.write(b"X-Creddy-delaying-tactic: ").await?;
let creds = tokio::select!{
r = stall(&mut stream) => r?, // this will never return Ok, just Err if it can't write to the stream
c = get_creds(&app_handle) => c?,
};
stream.write(b"\r\nContent-Length: ").await?;
stream.write(creds.as_bytes().len().to_string().as_bytes()).await?;
stream.write(b"\r\n\r\n").await?;
stream.write(creds.as_bytes()).await?;
stream.write(b"\r\n\r\n").await?;
Ok(())
}
use tokio::io::{stdin, stdout, BufReader, AsyncBufReadExt};
use crate::storage;
use tokio::sync::oneshot;
async fn get_creds(app_handle: &AppHandle) -> io::Result<String> {
app_handle.emit_all("credentials-request", ()).unwrap();
// let mut out = stdout();
// out.write_all(b"Enter passphrase: ").await?;
// out.flush().await?;
let (tx, rx) = oneshot::channel();
app_handle.once_global("passphrase-entered", |event| {
match event.payload() {
Some(p) => {tx.send(p.to_string());}
None => {tx.send("".to_string());} // will fail decryption, we just need to unblock the outer function
}
});
// Error is only returned if the rx is closed/dropped before receiving, which should never happen
let passphrase = rx.await.unwrap();
// let mut passphrase = String::new();
// let mut reader = BufReader::new(stdin());
// reader.read_line(&mut passphrase).await?;
Ok(storage::load(&passphrase.trim()))
}

183
src-tauri/src/ipc.rs Normal file
View 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
View 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
View 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;

View File

@ -1,30 +1,30 @@
#![cfg_attr( #![cfg_attr(
all(not(debug_assertions), target_os = "windows"), all(not(debug_assertions), target_os = "windows"),
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");
} }

View 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
View 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", &notification)?;
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
}

View 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", &notification)?;
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
View 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
View 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)));
}
}

View File

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

86
src-tauri/src/terminal.rs Normal file
View 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
View 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;
});
},
_ => (),
}
}

View File

@ -3,64 +3,86 @@
"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": { "bundle": {
"productName": "creddy", "active": true,
"version": "0.1.0" "category": "DeveloperTool",
}, "copyright": "",
"tauri": { "targets": "all",
"allowlist": { "externalBin": [],
"all": true "icon": [
}, "icons/32x32.png",
"bundle": { "icons/128x128.png",
"active": true, "icons/128x128@2x.png",
"category": "DeveloperTool", "icons/icon.icns",
"copyright": "", "icons/icon.ico"
"deb": { ],
"depends": [] "windows": {
}, "certificateThumbprint": null,
"externalBin": [], "digestAlgorithm": "sha256",
"icon": [ "timestampUrl": "",
"icons/32x32.png", "wix": {
"icons/128x128.png", "fragmentPaths": [
"icons/128x128@2x.png", "conf/cli.wxs"
"icons/icon.icns", ],
"icons/icon.ico" "componentRefs": [
], "CliBinary",
"identifier": "com.tauri.dev", "AddToPath"
"longDescription": "", ]
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
} }
}, },
"security": { "longDescription": "",
"csp": null "macOS": {
}, "entitlements": null,
"updater": { "exceptionDomain": "",
"active": false "frameworks": [],
"providerShortName": null,
"signingIdentity": null
}, },
"resources": [],
"shortDescription": "",
"linux": {
"deb": {
"depends": []
}
}
},
"productName": "creddy",
"version": "0.5.4",
"identifier": "creddy",
"plugins": {},
"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"
]
}
}
} }
} }

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

17
src/lib/errors.js Normal file
View 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
}

View File

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

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

35
src/lib/state.js Normal file
View 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();
}

View File

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

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

93
src/ui/ErrorAlert.svelte Normal file
View 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
View 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
View File

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

15
src/ui/KeyCombo.svelte Normal file
View 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
View 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
View File

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

View 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
View File

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

View File

@ -0,0 +1,8 @@
<script>
let classes = '';
export {classes as class}
</script>
<svg 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>

View File

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

View File

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

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

View 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
View 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
View File

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

8
src/ui/icons/key.svelte Normal file
View 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>

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

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

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

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

View File

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

View File

@ -0,0 +1,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>

View File

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

View File

@ -0,0 +1,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>

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

View File

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

View File

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

View File

@ -0,0 +1,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>

View File

@ -0,0 +1,22 @@
<script>
import { createEventDispatcher } from 'svelte';
import Setting from './Setting.svelte';
export let title;
export let value;
const dispatch = createEventDispatcher();
</script>
<Setting {title}>
<input
slot="input"
type="checkbox"
class="toggle toggle-success"
bind:checked={value}
on:change={e => dispatch('update', {value: e.target.checked})}
/>
<slot name="description" slot="description"></slot>
</Setting>

6
src/ui/settings/index.js Normal file
View 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