Compare commits

...

43 Commits

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
102 changed files with 13888 additions and 4560 deletions

4
.gitignore vendored
View File

@ -5,7 +5,3 @@ src-tauri/target/
# .env is system-specific # .env is system-specific
.env .env
.vscode .vscode
# just in case
credentials*
!credentials.rs

View File

@ -9,7 +9,7 @@ The following is a list of security features that I hope to add eventually, in a
* To defend against the possibility that an attacker could replace, say, the `aws` executable with a malicious one that snarfs your credentials and then passes the command on to the real one, maybe track the path (and maybe even the hash) of the executable, and raise a warning if this is the first time we've seen that one? Using the hash would be safer, but would also introduce a lot of false positives, since every time the application gets updated it would trigger. On the other hand, users should presumably know when they've updated things, so maybe it would be ok. On the _other_ other hand, if somebody doesn't use `aws` very often then it might be weeks or months in between updating it and actually using the updated executable, in which case they probably won't remember that this is the first time they've used it since updating. * 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. 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.) * Downgrade privileges after launching. In particular, if possible, disallow any kind of outgoing network access (obviously we have to bind the listening socket, but maybe we can filter that down to _just_ the ability to bind that particular address/port) and filesystem access outside of state db. I think this is doable on Linux, although it may involve high levels of `seccomp` grossness. No idea whether it's possible on Windows. Probably possible on MacOS although it may require lengths to which I am currently unwilling to go (e.g. pay for a certificate from Apple or something.)
* "Panic button" - if a potential attack is detected (e.g. the user denies a request but Creddy discovers the request has already succeeded somehow), offer a one-click option to lock out the current IAM user. (Sadly, you can't revoke session tokens, so this is the only way to limit a potential compromise). Not sure how feasible this is, session credentials may be limited with regard to what kind of IAM operations they can carry out.) * "Panic button" - if a potential attack is detected (e.g. the user denies a request but Creddy discovers the request has already succeeded somehow), offer a one-click option to lock out the current IAM user. Sadly, you can't revoke session tokens, so this is the only way to limit a potential compromise. Obviously this would require the current user having the ability to revoke their own IAM permissions.)
* Some kind of Yubikey or other HST integration. (Optional, since not everyone will have a HST.) This comes in two flavors: * 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. 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.) 2. (Less doable) Store the actual AWS secret key on the HST, and then ask the HST to just sign the whole `GetSessionToken` request. This requires that the HST support the exact signing algorithm required by AWS, which a) it probably doesn't, and b) is subject to change anyway. So this is probably not doable, but it's worth at least double-checking, since it would provide the maximum theoretical level of security. (That is, after initial setup, the application would never again see the long-lived AWS secret key.)
@ -19,9 +19,9 @@ Another possible approach is to _watch_ the files in question, and alert the use
Who exactly are we defending against and why? Who exactly are we defending against and why?
The basic idea behind Creddy is that it provides "gap coverage" between two wildly different security boundaries: 1) the older, user-based model, where all code executing as a given user is assumed to have the same level of trust, and 2) the newer, application-based model (most clearly seen on mobile devices) where that bondary instead exists around each _application_. The basic idea behind Creddy is that it provides "gap coverage" between two wildly different security models: 1) the older, user-based model, where all code executing as a given user is assumed to have the same level of trust, and 2) the newer, application-based model (most clearly seen on mobile devices) where that bondary instead exists around each _application_.
The unfortunate reality is that desktop environments are unlikely to adopt the latter model any time soon, if ever. This is primarily due to friction: Per-application security is a nightmare to manage. The only reason it works at all on mobile devices is because most mobile apps eschew the local device in favor of cloud-backed services where they can, e.g. for file storage. Arguably, the higher-friction trust model of mobile environments is in part _why_ mobile apps tend to be cloud-first. The unfortunate reality is that desktop environments are unlikely to adopt the latter model any time soon, if ever. This is primarily due to friction: Per-application security is a nightmare to manage. The only reason it works at all on mobile devices is because most mobile apps eschew the local device in favor of cloud-backed services where they can, e.g. for file storage. Arguably, the higher-friction trust model of mobile environments (along with the frequently-replaced nature of mobile devices) is in part _why_ mobile apps tend to be cloud-first.
Regardless, we live in a world where it's difficult to run untrusted code without giving it an inordinate level of access to the machine on which it runs. Creddy attempts to prevent that access from including your AWS credentials. The threat model is thus "untrusted code running under your user". This is especially likely to occur in the form of a supply-chain attack, where the compromised code is not your own but rather a dependency, or a dependency of a dependency, etc. Regardless, we live in a world where it's difficult to run untrusted code without giving it an inordinate level of access to the machine on which it runs. Creddy attempts to prevent that access from including your AWS credentials. The threat model is thus "untrusted code running under your user". This is especially likely to occur in the form of a supply-chain attack, where the compromised code is not your own but rather a dependency, or a dependency of a dependency, etc.
@ -31,13 +31,13 @@ There are lots of ways that I can imagine someone might try to circumvent Creddy
### Tricking Creddy into allowing a request that it shouldn't ### Tricking Creddy into allowing a request that it shouldn't
If an attacker is able to compromise Creddy's frontend, e.g. via a JS library that Creddy relies on, they could forge "request accepted" responses and cause the backend to hand out credentials to an unauthorized client. Most likely, the user would immediately be alerted to the fact that Something Is Up because as soon as the request came in, Creddy would pop up requesting permission. When the user (presumably) denied the request, Creddy would discover that the request had already been approved - we could make this a high-alert situation because it would be unlikely to happen unless something fishy were going on. Additionally, the request and (hopefully) what executable made it would be logged. If an attacker is able to compromise Creddy's frontend, e.g. via a JS library that Creddy relies on, they could forge "request accepted" responses and cause the backend to hand out credentials to an unauthorized client. Most likely, the user would immediately be alerted to the fact that Something Is Up because Creddy would pop up requesting then permission, and then immediately disappear again because the request had been approved. Additionally, the request and (hopefully) what executable made it would be logged.
### Tricking the user into allowing a request they didn't intend to ### Tricking the user into allowing a request they didn't intend to
If an attacker can edit the user's .bashrc or similar, they could theoretically insert a function or pre-command hook that wraps, say, the `aws` command, and dump the credentials before continuing on with the user's command. This would most likely alert the user because either a) the attacker is hijacking the original `aws` command and thus it doesn't do what the user told it to, or b) the user's original `aws` command proceeds as normal after the malicious one, and the user is alerted by the second request where there should only have been one. If an attacker can edit the user's .bashrc or similar, they could theoretically insert a function or pre-command hook that wraps, say, the `aws` command, and dump the credentials before continuing on with the user's command. The attacker could inject the credentials into the environment before running the original command, so as to avoid alerting the user by issuing a second credentials request.
A similar but more-difficult-to-detect attack would be replacing the `aws` executable, or any other executable that is always expected to ask for AWS credentials, with a malicious wrapper that snarfs the credentials before passing them through to the original command. Creddy could defend against this to a certain extent by storing the hash of the executable, as discussed above. Another attack along the same lines would be replacing the `aws` executable, or any other executable that is always expected to ask for AWS credentials, with a malicious wrapper that snarfs the credentials before passing them through to the original command. Creddy could defend against this to a certain extent by storing the hash of the executable, as discussed above.
### Pretending to be the user ### Pretending to be the user
@ -46,3 +46,5 @@ Most desktop environments don't prevent applications from simulating user-input
### Twiddling with Creddy's persistent state ### 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 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.

View File

@ -1,13 +1,18 @@
## Definitely ## Definitely
* Switch to "process" provider for AWS credentials (much less hacky) * ~~Switch to "process" provider for AWS credentials (much less hacky)~~
* Session timeout (plain duration, or activity-based?) * ~~Frontend needs to react when request is cancelled from backend~~
* ~Fix rehide behavior when new request comes in while old one is still being resolved~ * ~~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) * Additional hotkey configuration (approve/deny at the very least)
* Logging * Logging
* Icon * Icon
* Auto-updates * Auto-updates
* SSH key handling * SSH key handling
* Encrypted sync server
## Maybe ## Maybe
@ -17,3 +22,4 @@
* Make hotkey configuration a little more tolerant of slight mistiming * 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) * 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 * 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,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark"> <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" />

2190
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.4.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,7 +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",
"daisyui": "^2.51.5" "@tauri-apps/plugin-dialog": "^2.0.0-beta.5",
"@tauri-apps/plugin-os": "^2.0.0-beta.5",
"daisyui": "^4.12.8"
} }
} }

4455
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "creddy" name = "creddy"
version = "0.4.0" version = "0.5.4"
description = "A friendly AWS credentials manager" description = "A friendly AWS credentials manager"
authors = ["Joseph Montanaro"] authors = ["Joseph Montanaro"]
license = "" license = ""
@ -9,44 +9,65 @@ default-run = "creddy"
edition = "2021" edition = "2021"
rust-version = "1.57" rust-version = "1.57"
[[bin]]
name = "creddy_cli"
path = "src/bin/creddy_cli.rs"
[[bin]] [[bin]]
name = "creddy" name = "creddy"
path = "src/main.rs" 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.2", features = ["dialog", "dialog-open", "global-shortcut", "os-all", "system-tray"] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
sodiumoxide = "0.2.7" sodiumoxide = "0.2.7"
tokio = { version = ">=1.19", features = ["full"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] }
sysinfo = "0.26.8" sysinfo = "0.26.8"
aws-types = "0.52.0" aws-config = "1.5.3"
aws-sdk-sts = "0.22.0" aws-types = "1.3.2"
aws-smithy-types = "0.52.0" aws-sdk-sts = "1.33.0"
aws-config = "0.52.0" aws-smithy-types = "1.2.0"
dirs = { workspace = true }
thiserror = "1.0.38" thiserror = "1.0.38"
once_cell = "1.16.0" once_cell = "1.16.0"
strum = "0.24" strum = "0.24"
strum_macros = "0.24" strum_macros = "0.24"
auto-launch = "0.4.0" auto-launch = "0.4.0"
dirs = "5.0"
clap = { version = "3.2.23", features = ["derive"] }
is-terminal = "0.4.7" is-terminal = "0.4.7"
argon2 = { version = "0.5.0", features = ["std"] } argon2 = { version = "0.5.0", features = ["std"] }
chacha20poly1305 = { version = "0.10.1", features = ["std"] } chacha20poly1305 = { version = "0.10.1", features = ["std"] }
which = "4.4.0" which = "4.4.0"
windows = { version = "0.51.1", features = ["Win32_Foundation", "Win32_System_Pipes"] } 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

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

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

View File

@ -1,4 +1,5 @@
use std::error::Error; use std::error::Error;
use std::time::Duration;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use sqlx::{ use sqlx::{
@ -9,15 +10,18 @@ use sqlx::{
use tauri::{ use tauri::{
App, App,
AppHandle, AppHandle,
Manager,
async_runtime as rt, async_runtime as rt,
Manager,
RunEvent,
WindowEvent,
}; };
use tauri::menu::MenuItem;
use crate::{ use crate::{
config::{self, AppConfig}, config::{self, AppConfig},
credentials::Session, credentials::AppSession,
ipc, ipc,
server::Server, srv::{creddy_server, agent},
errors::*, errors::*,
shortcuts, shortcuts,
state::AppState, state::AppState,
@ -31,32 +35,40 @@ pub static APP: OnceCell<AppHandle> = OnceCell::new();
pub fn run() -> tauri::Result<()> { pub fn run() -> tauri::Result<()> {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
app.get_window("main") show_main_window(app)
.map(|w| w.show().error_popup("Failed to show main window")); .error_popup("Failed to show main window")
})) }))
.system_tray(tray::create()) .plugin(tauri_plugin_global_shortcut::Builder::default().build())
.on_system_tray_event(tray::handle_event) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
ipc::unlock, ipc::unlock,
ipc::lock,
ipc::reset_session,
ipc::set_passphrase,
ipc::respond, ipc::respond,
ipc::get_session_status, ipc::get_session_status,
ipc::save_credentials, 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::get_config,
ipc::save_config, ipc::save_config,
ipc::launch_terminal, ipc::launch_terminal,
ipc::get_setup_errors, ipc::get_setup_errors,
ipc::exit,
]) ])
.setup(|app| rt::block_on(setup(app))) .setup(|app| rt::block_on(setup(app)))
.build(tauri::generate_context!())? .build(tauri::generate_context!())?
.run(|app, run_event| match run_event { .run(|app, run_event| {
tauri::RunEvent::WindowEvent { label, event, .. } => match event { if let RunEvent::WindowEvent { event, .. } = run_event {
tauri::WindowEvent::CloseRequested { api, .. } => { if let WindowEvent::CloseRequested { api, .. } = event {
let _ = app.get_window(&label).map(|w| w.hide()); let _ = hide_main_window(app);
api.prevent_close(); api.prevent_close();
} }
_ => ()
} }
_ => ()
}); });
Ok(()) Ok(())
@ -75,8 +87,8 @@ pub async fn connect_db() -> Result<SqlitePool, SetupError> {
async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> { async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
APP.set(app.handle()).unwrap(); APP.set(app.handle().clone()).unwrap();
tray::setup(app)?;
// get_or_create_db_path doesn't create the actual db file, just the directory // 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 is_first_launch = !config::get_or_create_db_path()?.exists();
let pool = connect_db().await?; let pool = connect_db().await?;
@ -84,7 +96,7 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
let mut conf = match AppConfig::load(&pool).await { let mut conf = match AppConfig::load(&pool).await {
Ok(c) => c, Ok(c) => c,
Err(SetupError::ConfigParseError(_)) => { Err(LoadKvError::Invalid(_)) => {
setup_errors.push( setup_errors.push(
"Could not load configuration from database. Reverting to defaults.".into() "Could not load configuration from database. Reverting to defaults.".into()
); );
@ -93,8 +105,9 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
err => err?, err => err?,
}; };
let session = Session::load(&pool).await?; let app_session = AppSession::load(&pool).await?;
Server::start(app.handle())?; creddy_server::serve(app.handle().clone())?;
agent::serve(app.handle().clone())?;
config::set_auto_launch(conf.start_on_login)?; config::set_auto_launch(conf.start_on_login)?;
if let Err(_e) = config::set_auto_launch(conf.start_on_login) { if let Err(_e) = config::set_auto_launch(conf.start_on_login) {
@ -108,14 +121,64 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
setup_errors.push("Failed to register hotkeys. Hotkey settings have been disabled.".into()); setup_errors.push("Failed to register hotkeys. Hotkey settings have been disabled.".into());
} }
// if session is empty, this is probably the first launch, so don't autohide 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 { if !conf.start_minimized || is_first_launch {
app.get_window("main") show_main_window(&app.handle())?;
.ok_or(HandlerError::NoMainWindow)?
.show()?;
} }
let state = AppState::new(conf, session, pool, setup_errors); let state = AppState::new(conf, app_session, pool, setup_errors, desktop_is_gnome);
app.manage(state); 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(()) 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

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

View File

@ -1,203 +0,0 @@
use std::ffi::OsString;
use std::process::Command as ChildCommand;
#[cfg(windows)]
use std::time::Duration;
use clap::{
Command,
Arg,
ArgMatches,
ArgAction,
builder::PossibleValuesParser,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::credentials::Credentials;
use crate::errors::*;
use crate::server::{Request, Response};
use crate::shortcuts::ShortcutAction;
#[cfg(unix)]
use {
std::os::unix::process::CommandExt,
tokio::net::UnixStream,
};
#[cfg(windows)]
use {
tokio::net::windows::named_pipe::{NamedPipeClient, ClientOptions},
windows::Win32::Foundation::ERROR_PIPE_BUSY,
};
pub fn parser() -> Command<'static> {
Command::new("creddy")
.version(env!("CARGO_PKG_VERSION"))
.about("A friendly AWS credentials manager")
.subcommand(
Command::new("run")
.about("Launch Creddy")
)
.subcommand(
Command::new("get")
.about("Request AWS credentials from Creddy and output to stdout")
.arg(
Arg::new("base")
.short('b')
.long("base")
.action(ArgAction::SetTrue)
.help("Use base credentials instead of session credentials")
)
)
.subcommand(
Command::new("exec")
.about("Inject AWS credentials into the environment of another command")
.trailing_var_arg(true)
.arg(
Arg::new("base")
.short('b')
.long("base")
.action(ArgAction::SetTrue)
.help("Use base credentials instead of session credentials")
)
.arg(
Arg::new("command")
.multiple_values(true)
)
)
.subcommand(
Command::new("shortcut")
.about("Invoke an action normally trigged by hotkey (e.g. launch terminal)")
.arg(
Arg::new("action")
.value_parser(
PossibleValuesParser::new(["show_window", "launch_terminal"])
)
)
)
}
pub fn get(args: &ArgMatches) -> Result<(), CliError> {
let base = args.get_one("base").unwrap_or(&false);
let output = match get_credentials(*base)? {
Credentials::Base(creds) => serde_json::to_string(&creds).unwrap(),
Credentials::Session(creds) => serde_json::to_string(&creds).unwrap(),
};
println!("{output}");
Ok(())
}
pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
let base = *args.get_one("base").unwrap_or(&false);
let mut cmd_line = args.get_many("command")
.ok_or(ExecError::NoCommand)?;
let cmd_name: &String = cmd_line.next().unwrap(); // Clap guarantees that there will be at least one
let mut cmd = ChildCommand::new(cmd_name);
cmd.args(cmd_line);
match get_credentials(base)? {
Credentials::Base(creds) => {
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
},
Credentials::Session(creds) => {
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
cmd.env("AWS_SESSION_TOKEN", creds.session_token);
}
}
#[cfg(unix)]
{
// cmd.exec() never returns if successful
let e = cmd.exec();
match e.kind() {
std::io::ErrorKind::NotFound => {
let name: OsString = cmd_name.into();
Err(ExecError::NotFound(name).into())
}
_ => Err(ExecError::ExecutionFailed(e).into()),
}
}
#[cfg(windows)]
{
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let name: OsString = cmd_name.into();
return Err(ExecError::NotFound(name).into());
}
Err(e) => return Err(ExecError::ExecutionFailed(e).into()),
};
let status = child.wait()
.map_err(|e| ExecError::ExecutionFailed(e))?;
std::process::exit(status.code().unwrap_or(1));
};
}
pub fn invoke_shortcut(args: &ArgMatches) -> Result<(), CliError> {
let action = match args.get_one::<String>("action").map(|s| s.as_str()) {
Some("show_window") => ShortcutAction::ShowWindow,
Some("launch_terminal") => ShortcutAction::LaunchTerminal,
Some(&_) | None => unreachable!("Unknown shortcut action"), // guaranteed by clap
};
let req = Request::InvokeShortcut(action);
match make_request(&req) {
Ok(Response::Empty) => Ok(()),
Ok(r) => Err(RequestError::Unexpected(r).into()),
Err(e) => Err(e.into()),
}
}
fn get_credentials(base: bool) -> Result<Credentials, RequestError> {
let req = Request::GetAwsCredentials { base };
match make_request(&req) {
Ok(Response::Aws(creds)) => Ok(creds),
Ok(r) => Err(RequestError::Unexpected(r)),
Err(e) => Err(e),
}
}
#[tokio::main]
async fn make_request(req: &Request) -> Result<Response, RequestError> {
let mut data = serde_json::to_string(req).unwrap();
// server expects newline marking end of request
data.push('\n');
let mut stream = connect().await?;
stream.write_all(&data.as_bytes()).await?;
let mut buf = Vec::with_capacity(1024);
stream.read_to_end(&mut buf).await?;
let res: Result<Response, ServerError> = serde_json::from_slice(&buf)?;
Ok(res?)
}
#[cfg(windows)]
async fn connect() -> Result<NamedPipeClient, std::io::Error> {
// apparently attempting to connect can fail if there's already a client connected
loop {
match ClientOptions::new().open(r"\\.\pipe\creddy-requests") {
Ok(stream) => return Ok(stream),
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => (),
Err(e) => return Err(e),
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
#[cfg(unix)]
async fn connect() -> Result<UnixStream, std::io::Error> {
UnixStream::connect("/tmp/creddy.sock").await
}

View File

@ -1,6 +1,12 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt}; use sysinfo::{
System,
SystemExt,
Pid,
PidExt,
ProcessExt
};
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use crate::errors::*; use crate::errors::*;
@ -13,80 +19,25 @@ pub struct Client {
} }
pub fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> { pub fn get_client(pid: u32, parent: bool) -> Result<Client, ClientInfoError> {
dbg!(pid);
let sys_pid = Pid::from_u32(pid); let sys_pid = Pid::from_u32(pid);
let mut sys = System::new(); let mut sys = System::new();
sys.refresh_process(sys_pid); sys.refresh_process(sys_pid);
let proc = sys.process(sys_pid) let mut proc = sys.process(sys_pid)
.ok_or(ClientInfoError::ProcessNotFound)?; .ok_or(ClientInfoError::ProcessNotFound)?;
let parent_pid_sys = proc.parent() if parent {
.ok_or(ClientInfoError::ParentPidNotFound)?; let parent_pid_sys = proc.parent()
sys.refresh_process(parent_pid_sys); .ok_or(ClientInfoError::ParentPidNotFound)?;
let parent = sys.process(parent_pid_sys) sys.refresh_process(parent_pid_sys);
.ok_or(ClientInfoError::ParentProcessNotFound)?; proc = sys.process(parent_pid_sys)
.ok_or(ClientInfoError::ParentProcessNotFound)?;
}
let exe = match parent.exe() { let exe = match proc.exe() {
p if p == Path::new("") => None, p if p == Path::new("") => None,
p => Some(PathBuf::from(p)), p => Some(PathBuf::from(p)),
}; };
Ok(Client { pid: parent_pid_sys.as_u32(), exe }) Ok(Client { pid: proc.pid().as_u32(), exe })
} }
// async fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
// let state = APP.get().unwrap().state::<AppState>();
// let AppConfig {
// listen_addr: app_listen_addr,
// listen_port: app_listen_port,
// ..
// } = *state.config.read().await;
// let sockets_iter = netstat2::iterate_sockets_info(
// AddressFamilyFlags::IPV4,
// ProtocolFlags::TCP
// )?;
// for item in sockets_iter {
// let sock_info = item?;
// let proto_info = match sock_info.protocol_socket_info {
// ProtocolSocketInfo::Tcp(tcp_info) => tcp_info,
// ProtocolSocketInfo::Udp(_) => {continue;}
// };
// if proto_info.local_port == local_port
// && proto_info.remote_port == app_listen_port
// && proto_info.local_addr == app_listen_addr
// && proto_info.remote_addr == app_listen_addr
// {
// return Ok(sock_info.associated_pids)
// }
// }
// Ok(vec![])
// }
// Theoretically, on some systems, multiple processes can share a socket
// pub async fn get_clients(local_port: u16) -> Result<Vec<Option<Client>>, ClientInfoError> {
// let mut clients = Vec::new();
// let mut sys = System::new();
// for p in get_associated_pids(local_port).await? {
// let pid = Pid::from_u32(p);
// sys.refresh_process(pid);
// let proc = sys.process(pid)
// .ok_or(ClientInfoError::ProcessNotFound)?;
// let client = Client {
// pid: p,
// exe: proc.exe().to_path_buf(),
// };
// clients.push(Some(client));
// }
// if clients.is_empty() {
// clients.push(None);
// }
// Ok(clients)
// }

View File

@ -1,4 +1,5 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration;
use auto_launch::AutoLaunchBuilder; use auto_launch::AutoLaunchBuilder;
use is_terminal::IsTerminal; use is_terminal::IsTerminal;
@ -6,6 +7,7 @@ use serde::{Serialize, Deserialize};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::errors::*; use crate::errors::*;
use crate::kv;
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@ -45,6 +47,10 @@ impl HotkeysConfig {
pub struct AppConfig { pub struct AppConfig {
#[serde(default = "default_rehide_ms")] #[serde(default = "default_rehide_ms")]
pub rehide_ms: u64, 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")] #[serde(default = "default_start_minimized")]
pub start_minimized: bool, pub start_minimized: bool,
#[serde(default = "default_start_on_login")] #[serde(default = "default_start_on_login")]
@ -60,6 +66,8 @@ impl Default for AppConfig {
fn default() -> Self { fn default() -> Self {
AppConfig { AppConfig {
rehide_ms: default_rehide_ms(), rehide_ms: default_rehide_ms(),
auto_lock: default_auto_lock(),
lock_after: default_lock_after(),
start_minimized: default_start_minimized(), start_minimized: default_start_minimized(),
start_on_login: default_start_on_login(), start_on_login: default_start_on_login(),
terminal: default_term_config(), terminal: default_term_config(),
@ -70,31 +78,16 @@ impl Default for AppConfig {
impl AppConfig { impl AppConfig {
pub async fn load(pool: &SqlitePool) -> Result<AppConfig, SetupError> { pub async fn load(pool: &SqlitePool) -> Result<AppConfig, LoadKvError> {
let res = sqlx::query!("SELECT * from config where name = 'main'") let config = kv::load(pool, "config")
.fetch_optional(pool) .await?
.await?; .unwrap_or_else(|| AppConfig::default());
let row = match res { Ok(config)
Some(row) => row,
None => return Ok(AppConfig::default()),
};
Ok(serde_json::from_str(&row.data)?)
} }
pub async fn save(&self, pool: &SqlitePool) -> Result<(), sqlx::error::Error> { pub async fn save(&self, pool: &SqlitePool) -> Result<(), sqlx::error::Error> {
let data = serde_json::to_string(self).unwrap(); kv::save(pool, "config", self).await
sqlx::query(
"INSERT INTO config (name, data) VALUES ('main', ?)
ON CONFLICT (name) DO UPDATE SET data = ?"
)
.bind(&data)
.bind(&data)
.execute(pool)
.await?;
Ok(())
} }
} }
@ -187,6 +180,30 @@ fn default_hotkey_config() -> HotkeysConfig {
fn default_rehide_ms() -> u64 { 1000 } 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 // start minimized and on login only in production mode
fn default_start_minimized() -> bool { !cfg!(debug_assertions) } fn default_start_minimized() -> bool { !cfg!(debug_assertions) }
fn default_start_on_login() -> 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

@ -1,341 +0,0 @@
use std::fmt::{self, Formatter};
use std::time::{SystemTime, UNIX_EPOCH};
use aws_smithy_types::date_time::{DateTime, Format};
use argon2::{
Argon2,
Algorithm,
Version,
ParamsBuilder,
password_hash::rand_core::{RngCore, OsRng},
};
use chacha20poly1305::{
XChaCha20Poly1305,
XNonce,
aead::{
Aead,
AeadCore,
KeyInit,
Error as AeadError,
generic_array::GenericArray,
},
};
use serde::{
Serialize,
Deserialize,
Serializer,
Deserializer,
};
use serde::de::{self, Visitor};
use sqlx::SqlitePool;
use crate::errors::*;
#[derive(Clone, Debug)]
pub enum Session {
Unlocked{
base: BaseCredentials,
session: SessionCredentials,
},
Locked(LockedCredentials),
Empty,
}
impl Session {
pub async fn load(pool: &SqlitePool) -> Result<Self, SetupError> {
let res = sqlx::query!("SELECT * FROM credentials ORDER BY created_at desc")
.fetch_optional(pool)
.await?;
let row = match res {
Some(r) => r,
None => {return Ok(Session::Empty);}
};
let salt: [u8; 32] = row.salt
.try_into()
.map_err(|_e| SetupError::InvalidRecord)?;
let nonce = XNonce::from_exact_iter(row.nonce.into_iter())
.ok_or(SetupError::InvalidRecord)?;
let creds = LockedCredentials {
access_key_id: row.access_key_id,
secret_key_enc: row.secret_key_enc,
salt,
nonce,
};
Ok(Session::Locked(creds))
}
pub async fn renew_if_expired(&mut self) -> Result<bool, GetSessionError> {
match self {
Session::Unlocked{ref base, ref mut session} => {
if !session.is_expired() {
return Ok(false);
}
*session = SessionCredentials::from_base(base).await?;
Ok(true)
},
Session::Locked(_) => Err(GetSessionError::CredentialsLocked),
Session::Empty => Err(GetSessionError::CredentialsEmpty),
}
}
pub fn try_get(
&self
) -> Result<(&BaseCredentials, &SessionCredentials), GetCredentialsError> {
match self {
Self::Empty => Err(GetCredentialsError::Empty),
Self::Locked(_) => Err(GetCredentialsError::Locked),
Self::Unlocked{ ref base, ref session } => Ok((base, session))
}
}
}
#[derive(Clone, Debug)]
pub struct LockedCredentials {
pub access_key_id: String,
pub secret_key_enc: Vec<u8>,
pub salt: [u8; 32],
pub nonce: XNonce,
}
impl LockedCredentials {
pub async fn save(&self, pool: &SqlitePool) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO credentials (access_key_id, secret_key_enc, salt, nonce, created_at)
VALUES (?, ?, ?, ?, strftime('%s'))"
)
.bind(&self.access_key_id)
.bind(&self.secret_key_enc)
.bind(&self.salt[..])
.bind(&self.nonce[..])
.execute(pool)
.await?;
Ok(())
}
pub fn decrypt(&self, passphrase: &str) -> Result<BaseCredentials, UnlockError> {
let crypto = Crypto::new(passphrase, &self.salt)
.map_err(|e| CryptoError::Argon2(e))?;
let decrypted = crypto.decrypt(&self.nonce, &self.secret_key_enc)
.map_err(|e| CryptoError::Aead(e))?;
let secret_access_key = String::from_utf8(decrypted)
.map_err(|_| UnlockError::InvalidUtf8)?;
let creds = BaseCredentials {
access_key_id: self.access_key_id.clone(),
secret_access_key,
};
Ok(creds)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct BaseCredentials {
pub access_key_id: String,
pub secret_access_key: String,
}
impl BaseCredentials {
pub fn encrypt(&self, passphrase: &str) -> Result<LockedCredentials, CryptoError> {
let salt = Crypto::salt();
let crypto = Crypto::new(passphrase, &salt)?;
let (nonce, secret_key_enc) = crypto.encrypt(self.secret_access_key.as_bytes())?;
let locked = LockedCredentials {
access_key_id: self.access_key_id.clone(),
secret_key_enc,
salt,
nonce,
};
Ok(locked)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct SessionCredentials {
pub version: usize,
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: String,
#[serde(serialize_with = "serialize_expiration")]
#[serde(deserialize_with = "deserialize_expiration")]
pub expiration: DateTime,
}
impl SessionCredentials {
pub async fn from_base(base: &BaseCredentials) -> Result<Self, GetSessionError> {
let req_creds = aws_sdk_sts::Credentials::new(
&base.access_key_id,
&base.secret_access_key,
None, // token
None, //expiration
"Creddy", // "provider name" apparently
);
let config = aws_config::from_env()
.credentials_provider(req_creds)
.load()
.await;
let client = aws_sdk_sts::Client::new(&config);
let resp = client.get_session_token()
.duration_seconds(43_200)
.send()
.await?;
let aws_session = resp.credentials().ok_or(GetSessionError::EmptyResponse)?;
let access_key_id = aws_session.access_key_id()
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let secret_access_key = aws_session.secret_access_key()
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let session_token = aws_session.session_token()
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let expiration = aws_session.expiration()
.ok_or(GetSessionError::EmptyResponse)?
.clone();
let session_creds = SessionCredentials {
version: 1,
access_key_id,
secret_access_key,
session_token,
expiration,
};
#[cfg(debug_assertions)]
println!("Got new session:\n{}", serde_json::to_string(&session_creds).unwrap());
Ok(session_creds)
}
pub fn is_expired(&self) -> bool {
let current_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap() // doesn't panic because UNIX_EPOCH won't be later than now()
.as_secs();
let expire_ts = self.expiration.secs();
let remaining = expire_ts - (current_ts as i64);
remaining < 60
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum Credentials {
Base(BaseCredentials),
Session(SessionCredentials),
}
fn serialize_expiration<S>(exp: &DateTime, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer
{
// this only fails if the d/t is out of range, which it can't be for this format
let time_str = exp.fmt(Format::DateTime).unwrap();
serializer.serialize_str(&time_str)
}
struct DateTimeVisitor;
impl<'de> Visitor<'de> for DateTimeVisitor {
type Value = DateTime;
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
write!(formatter, "an RFC 3339 UTC string, e.g. \"2014-01-05T10:17:34Z\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<DateTime, E> {
DateTime::from_str(v, Format::DateTime)
.map_err(|_| E::custom(format!("Invalid date/time: {v}")))
}
}
fn deserialize_expiration<'de, D>(deserializer: D) -> Result<DateTime, D::Error>
where D: Deserializer<'de>
{
deserializer.deserialize_str(DateTimeVisitor)
}
struct Crypto {
cipher: XChaCha20Poly1305,
}
impl Crypto {
/// Argon2 params rationale:
///
/// m_cost is measured in KiB, so 128 * 1024 gives us 128MiB.
/// This should roughly double the memory usage of the application
/// while deriving the key.
///
/// p_cost is irrelevant since (at present) there isn't any parallelism
/// implemented, so we leave it at 1.
///
/// With the above m_cost, t_cost = 8 results in about 800ms to derive
/// a key on my (somewhat older) CPU. This is probably overkill, but
/// given that it should only have to happen ~once a day for most
/// usage, it should be acceptable.
#[cfg(not(debug_assertions))]
const MEM_COST: u32 = 128 * 1024;
#[cfg(not(debug_assertions))]
const TIME_COST: u32 = 8;
/// But since this takes a million years without optimizations,
/// we turn it way down in debug builds.
#[cfg(debug_assertions)]
const MEM_COST: u32 = 48 * 1024;
#[cfg(debug_assertions)]
const TIME_COST: u32 = 1;
fn new(passphrase: &str, salt: &[u8]) -> argon2::Result<Crypto> {
let params = ParamsBuilder::new()
.m_cost(Self::MEM_COST)
.p_cost(1)
.t_cost(Self::TIME_COST)
.build()
.unwrap(); // only errors if the given params are invalid
let hasher = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
params,
);
let mut key = [0; 32];
hasher.hash_password_into(passphrase.as_bytes(), &salt, &mut key)?;
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
Ok(Crypto { cipher })
}
fn salt() -> [u8; 32] {
let mut salt = [0; 32];
OsRng.fill_bytes(&mut salt);
salt
}
fn encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec<u8>), AeadError> {
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = self.cipher.encrypt(&nonce, data)?;
Ok((nonce, ciphertext))
}
fn decrypt(&self, nonce: &XNonce, data: &[u8]) -> Result<Vec<u8>, AeadError> {
self.cipher.decrypt(nonce, data)
}
}

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

View File

@ -1,23 +1,26 @@
use std::error::Error; use std::error::Error;
use std::convert::AsRef; use std::convert::AsRef;
use std::ffi::OsString; use std::ffi::OsString;
use std::sync::mpsc;
use std::string::FromUtf8Error; use std::string::FromUtf8Error;
use strum_macros::AsRefStr; use strum_macros::AsRefStr;
use thiserror::Error as ThisError; use thiserror::Error as ThisError;
use aws_sdk_sts::{ use aws_sdk_sts::{
types::SdkError as AwsSdkError, error::SdkError as AwsSdkError,
error::GetSessionTokenError, operation::get_session_token::GetSessionTokenError,
error::ProvideErrorMetadata,
};
use rfd::{
AsyncMessageDialog,
MessageLevel,
}; };
use sqlx::{ use sqlx::{
error::Error as SqlxError, error::Error as SqlxError,
migrate::MigrateError, migrate::MigrateError,
}; };
use tauri::api::dialog::{ use tauri::async_runtime as rt;
MessageDialogBuilder, use tauri_plugin_global_shortcut::Error as ShortcutError;
MessageDialogKind, use tokio::sync::oneshot::error::RecvError;
};
use serde::{ use serde::{
Serialize, Serialize,
Serializer, Serializer,
@ -26,30 +29,23 @@ use serde::{
}; };
pub trait ShowError { pub trait ShowError<T, E>
{
fn error_popup(self, title: &str); fn error_popup(self, title: &str);
fn error_popup_nowait(self, title: &str);
fn error_print(self); fn error_print(self);
fn error_print_prefix(self, prefix: &str); fn error_print_prefix(self, prefix: &str);
} }
impl<E: std::fmt::Display> ShowError for Result<(), E> { impl<T, E> ShowError<T, E> for Result<T, E>
where E: std::fmt::Display
{
fn error_popup(self, title: &str) { fn error_popup(self, title: &str) {
if let Err(e) = self { if let Err(e) = self {
let (tx, rx) = mpsc::channel(); let dialog = AsyncMessageDialog::new()
MessageDialogBuilder::new(title, format!("{e}")) .set_level(MessageLevel::Error)
.kind(MessageDialogKind::Error) .set_title(title)
.show(move |_| tx.send(true).unwrap()); .set_description(format!("{e}"));
rt::spawn(async move {dialog.show().await});
rx.recv().unwrap();
}
}
fn error_popup_nowait(self, title: &str) {
if let Err(e) = self {
MessageDialogBuilder::new(title, format!("{e}"))
.kind(MessageDialogKind::Error)
.show(|_| {})
} }
} }
@ -82,15 +78,33 @@ where
} }
fn serialize_upstream_err<E, M>(err: &E, map: &mut M) -> Result<(), M::Error> 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 where
E: Error, E: Error,
M: serde::ser::SerializeMap, M: serde::ser::SerializeMap,
{ {
let msg = err.source().map(|s| format!("{s}")); // let msg = err.source().map(|s| format!("{s}"));
map.serialize_entry("msg", &msg)?; // map.serialize_entry("msg", &msg)?;
map.serialize_entry("code", &None::<&str>)?; // map.serialize_entry("code", &None::<&str>)?;
map.serialize_entry("source", &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(()) Ok(())
} }
@ -114,10 +128,10 @@ pub enum SetupError {
InvalidRecord, // e.g. wrong size blob for nonce or salt InvalidRecord, // e.g. wrong size blob for nonce or salt
#[error("Error from database: {0}")] #[error("Error from database: {0}")]
DbError(#[from] SqlxError), DbError(#[from] SqlxError),
#[error("Error loading data: {0}")]
KvError(#[from] LoadKvError),
#[error("Error running migrations: {0}")] #[error("Error running migrations: {0}")]
MigrationError(#[from] MigrateError), MigrationError(#[from] MigrateError),
#[error("Error parsing configuration from database")]
ConfigParseError(#[from] serde_json::Error),
#[error("Failed to set up start-on-login: {0}")] #[error("Failed to set up start-on-login: {0}")]
AutoLaunchError(#[from] auto_launch::Error), AutoLaunchError(#[from] auto_launch::Error),
#[error("Failed to start listener: {0}")] #[error("Failed to start listener: {0}")]
@ -125,7 +139,7 @@ pub enum SetupError {
#[error("Failed to resolve data directory: {0}")] #[error("Failed to resolve data directory: {0}")]
DataDir(#[from] DataDirError), DataDir(#[from] DataDirError),
#[error("Failed to register hotkeys: {0}")] #[error("Failed to register hotkeys: {0}")]
RegisterHotkeys(#[from] tauri::Error), RegisterHotkeys(#[from] ShortcutError),
} }
@ -152,7 +166,7 @@ pub enum SendResponseError {
} }
// errors encountered while handling an HTTP request // errors encountered while handling a client request
#[derive(Debug, ThisError, AsRefStr)] #[derive(Debug, ThisError, AsRefStr)]
pub enum HandlerError { pub enum HandlerError {
#[error("Error writing to stream: {0}")] #[error("Error writing to stream: {0}")]
@ -163,8 +177,10 @@ pub enum HandlerError {
BadRequest(#[from] serde_json::Error), BadRequest(#[from] serde_json::Error),
#[error("HTTP request too large")] #[error("HTTP request too large")]
RequestTooLarge, RequestTooLarge,
#[error("Connection closed early by client")]
Abandoned,
#[error("Internal server error")] #[error("Internal server error")]
Internal, Internal(#[from] RecvError),
#[error("Error accessing credentials: {0}")] #[error("Error accessing credentials: {0}")]
NoCredentials(#[from] GetCredentialsError), NoCredentials(#[from] GetCredentialsError),
#[error("Error getting client details: {0}")] #[error("Error getting client details: {0}")]
@ -175,6 +191,14 @@ pub enum HandlerError {
NoMainWindow, NoMainWindow,
#[error("Request was denied")] #[error("Request was denied")]
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),
} }
@ -193,6 +217,12 @@ pub enum GetCredentialsError {
Locked, Locked,
#[error("No credentials are known")] #[error("No credentials are known")]
Empty, Empty,
#[error(transparent)]
Crypto(#[from] CryptoError),
#[error(transparent)]
Load(#[from] LoadCredentialsError),
#[error(transparent)]
GetSession(#[from] GetSessionError),
} }
@ -202,7 +232,7 @@ pub enum GetSessionError {
EmptyResponse, // SDK returned successfully but credentials are None EmptyResponse, // SDK returned successfully but credentials are None
#[error("Error response from AWS SDK: {0}")] #[error("Error response from AWS SDK: {0}")]
SdkError(#[from] AwsSdkError<GetSessionTokenError>), SdkError(#[from] AwsSdkError<GetSessionTokenError>),
#[error("Could not construt session: credentials are locked")] #[error("Could not construct session: credentials are locked")]
CredentialsLocked, CredentialsLocked,
#[error("Could not construct session: no credentials are known")] #[error("Could not construct session: no credentials are known")]
CredentialsEmpty, CredentialsEmpty,
@ -226,12 +256,77 @@ pub enum UnlockError {
} }
#[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)] #[derive(Debug, ThisError, AsRefStr)]
pub enum CryptoError { pub enum CryptoError {
#[error(transparent)] #[error(transparent)]
Argon2(#[from] argon2::Error), Argon2(#[from] argon2::Error),
#[error("Invalid passphrase")] // I think this is the only way decryption fails #[error("Invalid passphrase")] // I think this is the only way decryption fails
Aead(#[from] chacha20poly1305::aead::Error), Aead(#[from] chacha20poly1305::aead::Error),
#[error("App is currently locked")]
Locked,
#[error("No passphrase has been specified")]
Empty,
} }
@ -247,6 +342,8 @@ pub enum ClientInfoError {
#[cfg(windows)] #[cfg(windows)]
#[error("Could not determine PID of connected client")] #[error("Could not determine PID of connected client")]
WindowsError(#[from] windows::core::Error), WindowsError(#[from] windows::core::Error),
#[error("Could not determine PID of connected client")]
PidNotFound,
#[error(transparent)] #[error(transparent)]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
@ -273,7 +370,7 @@ pub enum RequestError {
#[error("Error response from server: {0}")] #[error("Error response from server: {0}")]
Server(ServerError), Server(ServerError),
#[error("Unexpected response from server")] #[error("Unexpected response from server")]
Unexpected(crate::server::Response), Unexpected(crate::srv::CliResponse),
#[error("The server did not respond with valid JSON")] #[error("The server did not respond with valid JSON")]
InvalidJson(#[from] serde_json::Error), InvalidJson(#[from] serde_json::Error),
#[error("Error reading/writing stream: {0}")] #[error("Error reading/writing stream: {0}")]
@ -325,6 +422,17 @@ pub enum LaunchTerminalError {
} }
#[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 // Serialize implementations
// ========================= // =========================
@ -344,11 +452,14 @@ impl Serialize for SerializeWrapper<&GetSessionTokenError> {
} }
impl_serialize_basic!(SetupError); impl_serialize_basic!(SetupError);
impl_serialize_basic!(GetCredentialsError); impl_serialize_basic!(GetCredentialsError);
impl_serialize_basic!(ClientInfoError); impl_serialize_basic!(ClientInfoError);
impl_serialize_basic!(WindowError); impl_serialize_basic!(WindowError);
impl_serialize_basic!(LockError);
impl_serialize_basic!(SaveCredentialsError);
impl_serialize_basic!(LoadCredentialsError);
impl_serialize_basic!(LoadSshKeyError);
impl Serialize for HandlerError { impl Serialize for HandlerError {

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,8 +1,13 @@
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use tauri::State; use sqlx::types::Uuid;
use tauri::{AppHandle, State};
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::credentials::{Session,BaseCredentials}; use crate::credentials::{
AppSession,
CredentialRecord,
SshKey,
};
use crate::errors::*; use crate::errors::*;
use crate::clientinfo::Client; use crate::clientinfo::Client;
use crate::state::AppState; use crate::state::AppState;
@ -13,14 +18,42 @@ use crate::terminal;
pub struct AwsRequestNotification { pub struct AwsRequestNotification {
pub id: u64, pub id: u64,
pub client: Client, pub client: Client,
pub name: Option<String>,
pub base: bool, 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)] #[derive(Debug, Serialize, Deserialize)]
pub struct RequestResponse { pub struct RequestResponse {
pub id: u64, pub id: u64,
pub approval: Approval, pub approval: Approval,
pub base: bool,
} }
@ -43,25 +76,75 @@ pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Resul
} }
#[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] #[tauri::command]
pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String, ()> { pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String, ()> {
let session = app_state.session.read().await; let session = app_state.app_session.read().await;
let status = match *session { let status = match *session {
Session::Locked(_) => "locked".into(), AppSession::Locked{..} => "locked".into(),
Session::Unlocked{..} => "unlocked".into(), AppSession::Unlocked{..} => "unlocked".into(),
Session::Empty => "empty".into() AppSession::Empty => "empty".into(),
}; };
Ok(status) Ok(status)
} }
#[tauri::command] #[tauri::command]
pub async fn save_credentials( pub async fn signal_activity(app_state: State<'_, AppState>) -> Result<(), ()> {
credentials: BaseCredentials, app_state.signal_activity().await;
passphrase: String, Ok(())
}
#[tauri::command]
pub async fn save_credential(
record: CredentialRecord,
app_state: State<'_, AppState> app_state: State<'_, AppState>
) -> Result<(), UnlockError> { ) -> Result<(), SaveCredentialsError> {
app_state.new_creds(credentials, &passphrase).await 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)
} }
@ -83,7 +166,8 @@ pub async fn save_config(config: AppConfig, app_state: State<'_, AppState>) -> R
#[tauri::command] #[tauri::command]
pub async fn launch_terminal(base: bool) -> Result<(), LaunchTerminalError> { pub async fn launch_terminal(base: bool) -> Result<(), LaunchTerminalError> {
terminal::launch(base).await let res = terminal::launch(base).await;
res
} }
@ -91,3 +175,9 @@ pub async fn launch_terminal(base: bool) -> Result<(), LaunchTerminalError> {
pub async fn get_setup_errors(app_state: State<'_, AppState>) -> Result<Vec<String>, ()> { pub async fn get_setup_errors(app_state: State<'_, AppState>) -> Result<Vec<String>, ()> {
Ok(app_state.setup_errors.clone()) 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);
}
}

View File

@ -1,12 +1,12 @@
pub mod app; pub mod app;
pub mod cli;
mod config; mod config;
mod credentials; mod credentials;
pub mod errors; pub mod errors;
mod clientinfo; mod clientinfo;
mod ipc; mod ipc;
mod kv;
mod state; mod state;
mod server; mod srv;
mod shortcuts; mod shortcuts;
mod terminal; mod terminal;
mod tray; mod tray;

View File

@ -3,23 +3,24 @@
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
use creddy::{ use creddy::{
app, app,
cli,
errors::ShowError, errors::ShowError,
}; };
use creddy_cli::{Action, Cli};
fn main() { fn main() {
let res = match cli::parser().get_matches().subcommand() { let cli = Cli::parse();
None | Some(("run", _)) => { let res = match cli.action {
app::run().error_popup("Creddy failed to start"); None | Some(Action::Run) => {
app::run().error_popup("Creddy encountered an error");
Ok(()) Ok(())
}, },
Some(("get", m)) => cli::get(m), Some(Action::Get(args)) => creddy_cli::get(args, cli.global_args),
Some(("exec", m)) => cli::exec(m), Some(Action::Exec(args)) => creddy_cli::exec(args, cli.global_args),
Some(("shortcut", m)) => cli::invoke_shortcut(m), Some(Action::Shortcut(args)) => creddy_cli::invoke_shortcut(args, cli.global_args),
_ => unreachable!(),
}; };
if let Err(e) = res { if let Err(e) = res {

View File

@ -1,126 +0,0 @@
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot;
use serde::{Serialize, Deserialize};
use tauri::{AppHandle, Manager};
use crate::errors::*;
use crate::clientinfo::{self, Client};
use crate::credentials::Credentials;
use crate::ipc::{Approval, AwsRequestNotification};
use crate::state::AppState;
use crate::shortcuts::{self, ShortcutAction};
#[cfg(windows)]
mod server_win;
#[cfg(windows)]
pub use server_win::Server;
#[cfg(windows)]
use server_win::Stream;
#[cfg(unix)]
mod server_unix;
#[cfg(unix)]
pub use server_unix::Server;
#[cfg(unix)]
use server_unix::Stream;
#[derive(Serialize, Deserialize)]
pub enum Request {
GetAwsCredentials{
base: bool,
},
InvokeShortcut(ShortcutAction),
}
#[derive(Debug, Serialize, Deserialize)]
pub enum Response {
Aws(Credentials),
Empty,
}
async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> Result<(), HandlerError>
{
// read from stream until delimiter is reached
let mut buf: Vec<u8> = Vec::with_capacity(1024); // requests are small, 1KiB is more than enough
let mut n = 0;
loop {
n += stream.read_buf(&mut buf).await?;
if let Some(&b'\n') = buf.last() {
break;
}
else if n >= 1024 {
return Err(HandlerError::RequestTooLarge);
}
}
let client = clientinfo::get_process_parent_info(client_pid)?;
let req: Request = serde_json::from_slice(&buf)?;
let res = match req {
Request::GetAwsCredentials{ base } => get_aws_credentials(base, client, app_handle).await,
Request::InvokeShortcut(action) => invoke_shortcut(action).await,
};
let res = serde_json::to_vec(&res).unwrap();
stream.write_all(&res).await?;
Ok(())
}
async fn invoke_shortcut(action: ShortcutAction) -> Result<Response, HandlerError> {
shortcuts::exec_shortcut(action);
Ok(Response::Empty)
}
async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle) -> Result<Response, HandlerError> {
let state = app_handle.state::<AppState>();
let rehide_ms = {
let config = state.config.read().await;
config.rehide_ms
};
let lease = state.acquire_visibility_lease(rehide_ms).await
.map_err(|_e| HandlerError::NoMainWindow)?; // automate this conversion eventually?
let (chan_send, chan_recv) = oneshot::channel();
let request_id = state.register_request(chan_send).await;
// if an error occurs in any of the following, we want to abort the operation
// but ? returns immediately, and we want to unregister the request before returning
// so we bundle it all up in an async block and return a Result so we can handle errors
let proceed = async {
let notification = AwsRequestNotification {id: request_id, client, base};
app_handle.emit_all("credentials-request", &notification)?;
match chan_recv.await {
Ok(Approval::Approved) => {
if base {
let creds = state.base_creds_cloned().await?;
Ok(Response::Aws(Credentials::Base(creds)))
}
else {
let creds = state.session_creds_cloned().await?;
Ok(Response::Aws(Credentials::Session(creds)))
}
},
Ok(Approval::Denied) => Err(HandlerError::Denied),
Err(_e) => Err(HandlerError::Internal),
}
};
let result = match proceed.await {
Ok(r) => Ok(r),
Err(e) => {
state.unregister_request(request_id).await;
Err(e)
}
};
lease.release();
result
}

View File

@ -1,59 +0,0 @@
use std::io::ErrorKind;
use tokio::net::{UnixListener, UnixStream};
use tauri::{
AppHandle,
Manager,
async_runtime as rt,
};
use crate::errors::*;
pub type Stream = UnixStream;
pub struct Server {
listener: UnixListener,
app_handle: AppHandle,
}
impl Server {
pub fn start(app_handle: AppHandle) -> std::io::Result<()> {
match std::fs::remove_file("/tmp/creddy.sock") {
Ok(_) => (),
Err(e) if e.kind() == ErrorKind::NotFound => (),
Err(e) => return Err(e),
}
let listener = UnixListener::bind("/tmp/creddy.sock")?;
let srv = Server { listener, app_handle };
rt::spawn(srv.serve());
Ok(())
}
async fn serve(self) {
loop {
self.try_serve()
.await
.error_print_prefix("Error accepting request: ");
}
}
async fn try_serve(&self) -> Result<(), HandlerError> {
let (stream, _addr) = self.listener.accept().await?;
let new_handle = self.app_handle.app_handle();
let client_pid = get_client_pid(&stream)?;
rt::spawn(async move {
super::handle(stream, new_handle, client_pid)
.await
.error_print_prefix("Error responding to request: ");
});
Ok(())
}
}
fn get_client_pid(stream: &UnixStream) -> std::io::Result<u32> {
let cred = stream.peer_cred()?;
Ok(cred.pid().unwrap() as u32)
}

View File

@ -1,75 +0,0 @@
use tokio::{
net::windows::named_pipe::{
NamedPipeServer,
ServerOptions,
},
sync::oneshot,
};
use windows::Win32:: {
Foundation::HANDLE,
System::Pipes::GetNamedPipeClientProcessId,
};
use std::os::windows::io::AsRawHandle;
use tauri::async_runtime as rt;
use crate::errors::*;
// used by parent module
pub type Stream = NamedPipeServer;
pub struct Server {
listener: NamedPipeServer,
app_handle: AppHandle,
}
impl Server {
pub fn start(app_handle: AppHandle) -> std::io::Result<()> {
let listener = ServerOptions::new()
.first_pipe_instance(true)
.create(r"\\.\pipe\creddy-requests")?;
let srv = Server {listener, app_handle};
rt::spawn(srv.serve());
Ok(())
}
async fn serve(mut self) {
loop {
if let Err(e) = self.try_serve().await {
eprintln!("Error accepting connection: {e}");
}
}
}
async fn try_serve(&mut self) -> Result<(), HandlerError> {
// connect() just waits for a client to connect, it doesn't return anything
self.listener.connect().await?;
// create a new pipe instance to listen for the next client, and swap it in
let new_listener = ServerOptions::new().create(r"\\.\pipe\creddy-requests")?;
let mut stream = std::mem::replace(&mut self.listener, new_listener);
let new_handle = self.app_handle.app_handle();
let client_pid = get_client_pid(&stream)?;
rt::spawn(async move {
super::handle(stream, app_handle)
.await
.error_print_prefix("Error responding to request: ");
});
Ok(())
}
}
fn get_client_pid(pipe: &NamedPipeServer) -> Result<u32, ClientInfoError> {
let raw_handle = pipe.as_raw_handle();
let mut pid = 0u32;
let handle = HANDLE(raw_handle as _);
unsafe { GetNamedPipeClientProcessId(handle, &mut pid as *mut u32)? };
pid
}

View File

@ -1,12 +1,13 @@
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use tauri::{ use tauri::async_runtime as rt;
GlobalShortcutManager,
Manager, use tauri_plugin_global_shortcut::{
async_runtime as rt, GlobalShortcutExt,
Error as ShortcutError,
}; };
use crate::app::APP; use crate::app::{self, APP};
use crate::config::HotkeysConfig; use crate::config::HotkeysConfig;
use crate::errors::*; use crate::errors::*;
use crate::terminal; use crate::terminal;
@ -21,38 +22,46 @@ pub enum ShortcutAction {
pub fn exec_shortcut(action: ShortcutAction) { pub fn exec_shortcut(action: ShortcutAction) {
match action { match action {
ShortcutAction::LaunchTerminal => launch_terminal(),
ShortcutAction::ShowWindow => { ShortcutAction::ShowWindow => {
let app = APP.get().unwrap(); let app = APP.get().unwrap();
app.get_window("main") app::show_main_window(app)
.ok_or("Couldn't find application main window") .error_popup("Failed to show Creddy");
.map(|w| w.show().error_popup("Failed to show window"))
.error_popup("Failed to show window");
},
ShortcutAction::LaunchTerminal => {
rt::spawn(async {
terminal::launch(false).await.error_popup("Failed to launch terminal");
});
}, },
} }
} }
pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> tauri::Result<()> { 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 app = APP.get().unwrap();
let mut manager = app.global_shortcut_manager(); let shortcuts = app.global_shortcut();
manager.unregister_all()?; shortcuts.unregister_all([
hotkeys.show_window.keys.as_str(),
hotkeys.launch_terminal.keys.as_str(),
])?;
if hotkeys.show_window.enabled { if hotkeys.show_window.enabled {
manager.register( shortcuts.on_shortcut(
&hotkeys.show_window.keys, hotkeys.show_window.keys.as_str(),
|| exec_shortcut(ShortcutAction::ShowWindow) |app, _shortcut, _event| {
app::show_main_window(app).error_popup("Failed to show Creddy")
}
)?; )?;
} }
if hotkeys.launch_terminal.enabled { if hotkeys.launch_terminal.enabled {
manager.register( shortcuts.on_shortcut(
&hotkeys.launch_terminal.keys, hotkeys.launch_terminal.keys.as_str(),
|| exec_shortcut(ShortcutAction::LaunchTerminal) |_app, _shortcut, _event| launch_terminal()
)?; )?;
} }

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

View File

@ -1,23 +1,34 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::time::Duration; use std::time::Duration;
use time::OffsetDateTime;
use tokio::{ use tokio::{
sync::RwLock, sync::{RwLock, RwLockReadGuard},
sync::oneshot::{self, Sender}, sync::oneshot::{self, Sender},
}; };
use ssh_agent_lib::proto::message::Identity;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use sqlx::types::Uuid;
use tauri::{ use tauri::{
Manager, Manager,
async_runtime as rt, async_runtime as rt,
}; };
use crate::app;
use crate::credentials::{ use crate::credentials::{
Session, AppSession,
BaseCredentials, AwsSessionCredential,
SessionCredentials, SshKey,
}; };
use crate::{config, config::AppConfig}; use crate::{config, config::AppConfig};
use crate::ipc::{self, Approval}; use crate::credentials::{
AwsBaseCredential,
Credential,
CredentialRecord,
PersistentCredential
};
use crate::ipc::{self, RequestResponse};
use crate::errors::*; use crate::errors::*;
use crate::shortcuts; use crate::shortcuts;
@ -35,35 +46,43 @@ impl Visibility {
fn acquire(&mut self, delay_ms: u64) -> Result<VisibilityLease, WindowError> { fn acquire(&mut self, delay_ms: u64) -> Result<VisibilityLease, WindowError> {
let app = crate::app::APP.get().unwrap(); let app = crate::app::APP.get().unwrap();
let window = app.get_window("main") let window = app.get_webview_window("main")
.ok_or(WindowError::NoMainWindow)?; .ok_or(WindowError::NoMainWindow)?;
self.leases += 1; 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() { if self.original.is_none() {
let is_visible = window.is_visible()?;
self.original = Some(is_visible); self.original = Some(is_visible);
if !is_visible {
window.show()?;
}
} }
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()?; window.set_focus()?;
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
let lease = VisibilityLease { notify: tx }; let lease = VisibilityLease { notify: tx };
let delay = Duration::from_millis(delay_ms); let delay = Duration::from_millis(delay_ms);
let handle = app.app_handle();
rt::spawn(async move { rt::spawn(async move {
// We don't care if it's an error; lease being dropped should be handled identically // We don't care if it's an error; lease being dropped should be handled identically
let _ = rx.await; let _ = rx.await;
tokio::time::sleep(delay).await; tokio::time::sleep(delay).await;
// we can't use `self` here because we would have to move it into the async block // we can't use `self` here because we would have to move it into the async block
let state = handle.state::<AppState>(); let state = app.state::<AppState>();
let mut visibility = state.visibility.write().await; let mut visibility = state.visibility.write().await;
visibility.leases -= 1; visibility.leases -= 1;
if visibility.leases == 0 { if visibility.leases == 0 {
if let Some(false) = visibility.original { if let Some(false) = visibility.original {
window.hide().error_print(); app::hide_main_window(app).error_print();
} }
visibility.original = None; visibility.original = None;
} }
@ -91,12 +110,16 @@ impl VisibilityLease {
#[derive(Debug)] #[derive(Debug)]
pub struct AppState { pub struct AppState {
pub config: RwLock<AppConfig>, pub config: RwLock<AppConfig>,
pub session: RwLock<Session>, 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 request_count: RwLock<u64>,
pub waiting_requests: RwLock<HashMap<u64, Sender<Approval>>>, pub waiting_requests: RwLock<HashMap<u64, Sender<RequestResponse>>>,
pub pending_terminal_request: RwLock<bool>, pub pending_terminal_request: RwLock<bool>,
// setup_errors is never modified and so doesn't need to be wrapped in RwLock // these are never modified and so don't need to be wrapped in RwLocks
pub setup_errors: Vec<String>, pub setup_errors: Vec<String>,
pub desktop_is_gnome: bool,
pool: sqlx::SqlitePool, pool: sqlx::SqlitePool,
visibility: RwLock<Visibility>, visibility: RwLock<Visibility>,
} }
@ -104,28 +127,67 @@ pub struct AppState {
impl AppState { impl AppState {
pub fn new( pub fn new(
config: AppConfig, config: AppConfig,
session: Session, app_session: AppSession,
pool: SqlitePool, pool: SqlitePool,
setup_errors: Vec<String>, setup_errors: Vec<String>,
desktop_is_gnome: bool,
) -> AppState { ) -> AppState {
AppState { AppState {
config: RwLock::new(config), config: RwLock::new(config),
session: RwLock::new(session), app_session: RwLock::new(app_session),
aws_sessions: RwLock::new(HashMap::new()),
last_activity: RwLock::new(OffsetDateTime::now_utc()),
request_count: RwLock::new(0), request_count: RwLock::new(0),
waiting_requests: RwLock::new(HashMap::new()), waiting_requests: RwLock::new(HashMap::new()),
pending_terminal_request: RwLock::new(false), pending_terminal_request: RwLock::new(false),
setup_errors, setup_errors,
desktop_is_gnome,
pool, pool,
visibility: RwLock::new(Visibility::new()), visibility: RwLock::new(Visibility::new()),
} }
} }
pub async fn new_creds(&self, base_creds: BaseCredentials, passphrase: &str) -> Result<(), UnlockError> { pub async fn save_credential(&self, record: CredentialRecord) -> Result<(), SaveCredentialsError> {
let locked = base_creds.encrypt(passphrase)?; let session = self.app_session.read().await;
// do this first so that if it fails we don't save bad credentials let crypto = session.try_get_crypto()?;
self.new_session(base_creds).await?; record.save(crypto, &self.pool).await
locked.save(&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(()) Ok(())
} }
@ -149,7 +211,7 @@ impl AppState {
Ok(()) Ok(())
} }
pub async fn register_request(&self, sender: Sender<Approval>) -> u64 { pub async fn register_request(&self, sender: Sender<RequestResponse>) -> u64 {
let count = { let count = {
let mut c = self.request_count.write().await; let mut c = self.request_count.write().await;
*c += 1; *c += 1;
@ -172,53 +234,113 @@ impl AppState {
} }
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> { pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
if let Approval::Approved = response.approval {
let mut session = self.session.write().await;
session.renew_if_expired().await?;
}
let mut waiting_requests = self.waiting_requests.write().await; let mut waiting_requests = self.waiting_requests.write().await;
waiting_requests waiting_requests
.remove(&response.id) .remove(&response.id)
.ok_or(SendResponseError::NotFound)? .ok_or(SendResponseError::NotFound)?
.send(response.approval) .send(response)
.map_err(|_| SendResponseError::Abandoned) .map_err(|_| SendResponseError::Abandoned)
} }
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> { pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
let base_creds = match *self.session.read().await { let mut session = self.app_session.write().await;
Session::Empty => {return Err(UnlockError::NoCredentials);}, session.unlock(passphrase)
Session::Unlocked{..} => {return Err(UnlockError::NotLocked);}, }
Session::Locked(ref locked) => locked.decrypt(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?,
}; };
// Read lock is dropped here, so this doesn't deadlock Ok(creds)
self.new_session(base_creds).await?;
Ok(())
} }
pub async fn is_unlocked(&self) -> bool { pub async fn get_aws_session(&self, name: Option<String>) -> Result<RwLockReadGuard<'_, AwsSessionCredential>, GetCredentialsError> {
let session = self.session.read().await; let app_session = self.app_session.read().await;
matches!(*session, Session::Unlocked{..}) 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 base_creds_cloned(&self) -> Result<BaseCredentials, GetCredentialsError> { pub async fn ssh_name_from_pubkey(&self, pubkey: &[u8]) -> Result<String, GetCredentialsError> {
let app_session = self.session.read().await; let k = SshKey::name_from_pubkey(pubkey, &self.pool).await?;
let (base, _session) = app_session.try_get()?; Ok(k)
Ok(base.clone())
} }
pub async fn session_creds_cloned(&self) -> Result<SessionCredentials, GetCredentialsError> { pub async fn sshkey_by_name(&self, name: &str) -> Result<SshKey, GetCredentialsError> {
let app_session = self.session.read().await; let app_session = self.app_session.read().await;
let (_bsae, session) = app_session.try_get()?; let crypto = app_session.try_get_crypto()?;
Ok(session.clone()) let k = SshKey::load_by_name(name, crypto, &self.pool).await?;
Ok(k)
} }
async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> { pub async fn signal_activity(&self) {
let session = SessionCredentials::from_base(&base).await?; let mut last_activity = self.last_activity.write().await;
let mut app_session = self.session.write().await; *last_activity = OffsetDateTime::now_utc();
*app_session = Session::Unlocked {base, session}; }
Ok(())
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<(), ()> { pub async fn register_terminal_request(&self) -> Result<(), ()> {
@ -238,3 +360,36 @@ impl AppState {
*req = false; *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,6 +1,8 @@
use std::process::Command; use std::process::Command;
use std::time::Duration;
use tauri::Manager; use tauri::{AppHandle, Manager};
use tokio::time::sleep;
use crate::app::APP; use crate::app::APP;
use crate::errors::*; use crate::errors::*;
@ -16,6 +18,18 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> {
return Ok(()); 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 mut cmd = {
let config = state.config.read().await; let config = state.config.read().await;
let mut cmd = Command::new(&config.terminal.exec); let mut cmd = Command::new(&config.terminal.exec);
@ -23,56 +37,50 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> {
cmd cmd
}; };
// if session is unlocked or empty, wait for credentials from frontend // if session is locked, wait for credentials from frontend
if !state.is_unlocked().await { if state.is_locked().await {
app.emit_all("launch-terminal-request", ())?;
let lease = state.acquire_visibility_lease(0).await let lease = state.acquire_visibility_lease(0).await
.map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually? .map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually?
let (tx, rx) = tokio::sync::oneshot::channel(); let (tx, rx) = tokio::sync::oneshot::channel();
app.once_global("credentials-event", move |e| { app.once("unlocked", move |_| {
let success = match e.payload() { let _ = tx.send(());
Some("\"unlocked\"") | Some("\"entered\"") => true,
_ => false,
};
let _ = tx.send(success);
}); });
if !rx.await.unwrap_or(false) { let timeout = Duration::from_secs(60);
state.unregister_terminal_request().await; tokio::select! {
return Ok(()); // request was canceled by user // if the frontend is unlocked within 60 seconds, release visibility lock and proceed
} _ = rx => lease.release(),
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.");
// more lock-management return Ok(());
{ },
let app_session = state.session.read().await;
// session should really be unlocked at this point, but if the frontend misbehaves
// (i.e. lies about unlocking) we could end up here with a locked session
// this will result in an error popup to the user (see main hotkey handler)
let (base_creds, session_creds) = app_session.try_get()?;
if use_base {
cmd.env("AWS_ACCESS_KEY_ID", &base_creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", &base_creds.secret_access_key);
}
else {
cmd.env("AWS_ACCESS_KEY_ID", &session_creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", &session_creds.secret_access_key);
cmd.env("AWS_SESSION_TOKEN", &session_creds.session_token);
} }
} }
let res = match cmd.spawn() { // 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(()), Ok(_) => Ok(()),
Err(e) if std::io::ErrorKind::NotFound == e.kind() => { Err(e) if std::io::ErrorKind::NotFound == e.kind() => {
Err(ExecError::NotFound(cmd.get_program().to_owned())) Err(ExecError::NotFound(cmd.get_program().to_owned()))
}, },
Err(e) => Err(ExecError::ExecutionFailed(e)), Err(e) => Err(ExecError::ExecutionFailed(e)),
}; }?;
state.unregister_terminal_request().await;
res?; // ? auto-conversion is more liberal than .into()
Ok(()) Ok(())
} }

View File

@ -1,36 +1,49 @@
use tauri::{ use tauri::{
App,
AppHandle, AppHandle,
Manager, Manager,
SystemTray, async_runtime as rt,
SystemTrayEvent, };
SystemTrayMenu, use tauri::menu::{
CustomMenuItem, MenuBuilder,
MenuEvent,
MenuItemBuilder,
}; };
use crate::app;
use crate::state::AppState;
pub fn create() -> SystemTray {
let show = CustomMenuItem::new("show".to_string(), "Show");
let quit = CustomMenuItem::new("exit".to_string(), "Exit");
let menu = SystemTrayMenu::new() pub fn setup(app: &App) -> tauri::Result<()> {
.add_item(show) let show_hide = MenuItemBuilder::with_id("show_hide", "Show").build(app)?;
.add_item(quit); let exit = MenuItemBuilder::with_id("exit", "Exit").build(app)?;
SystemTray::new().with_menu(menu) 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(())
} }
pub fn handle_event(app: &AppHandle, event: SystemTrayEvent) { fn handle_event(app_handle: &AppHandle, event: MenuEvent) {
match event { match event.id.0.as_str() {
SystemTrayEvent::MenuItemClick{ id, .. } => { "exit" => app_handle.exit(0),
match id.as_str() { "show_hide" => {
"exit" => app.exit(0), let _ = app::toggle_main_window(app_handle);
"show" => { let new_handle = app_handle.clone();
let _ = app.get_window("main").map(|w| w.show()); rt::spawn(async move {
} let state = new_handle.state::<AppState>();
_ => (), state.signal_activity().await;
} });
} },
_ => (), _ => (),
} }
} }

View File

@ -3,64 +3,57 @@
"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.4.0" "category": "DeveloperTool",
}, "copyright": "",
"tauri": { "targets": "all",
"allowlist": { "externalBin": [],
"os": {"all": true}, "icon": [
"dialog": {"open": true} "icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "",
"wix": {
"fragmentPaths": [
"conf/cli.wxs"
],
"componentRefs": [
"CliBinary",
"AddToPath"
]
}
}, },
"bundle": { "longDescription": "",
"active": true, "macOS": {
"category": "DeveloperTool", "entitlements": null,
"copyright": "", "exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"linux": {
"deb": { "deb": {
"depends": [] "depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "creddy",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "",
"wix": {
"fragmentPaths": ["conf/cli.wxs"],
"componentRefs": ["CliBinary", "AddToPath"]
}
} }
}, }
"security": { },
"csp": { "productName": "creddy",
"default-src": ["'self'"], "version": "0.5.4",
"style-src": ["'self'", "'unsafe-inline'"] "identifier": "creddy",
} "plugins": {},
}, "app": {
"updater": {
"active": false
},
"windows": [ "windows": [
{ {
"fullscreen": false, "fullscreen": false,
@ -72,9 +65,24 @@
"visible": false "visible": false
} }
], ],
"systemTray": { "trayIcon": {
"id": "main",
"iconPath": "icons/icon.png", "iconPath": "icons/icon.png",
"iconAsTemplate": true "iconAsTemplate": true
},
"security": {
"csp": {
"style-src": [
"'self'",
"'unsafe-inline'"
],
"default-src": [
"'self'"
],
"connect-src": [
"ipc: http://ipc.localhost"
]
}
} }
} }
} }

View File

@ -1,42 +1,72 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/core';
import { getVersion } from '@tauri-apps/api/app';
import { appState, acceptRequest } from './lib/state.js'; import { appState, acceptRequest, cleanupRequest } from './lib/state.js';
import { views, currentView, navigate } from './lib/routing.js'; import { views, currentView, navigate } from './lib/routing.js';
import Approve from './views/Approve.svelte';
import CreatePassphrase from './views/CreatePassphrase.svelte';
import Unlock from './views/Unlock.svelte';
$views = import.meta.glob('./views/*.svelte', {eager: true}); // set up app state
navigate('Home');
invoke('get_config').then(config => $appState.config = config); invoke('get_config').then(config => $appState.config = config);
invoke('get_session_status').then(status => $appState.sessionStatus = status);
listen('credentials-request', (tauriEvent) => { getVersion().then(version => $appState.appVersion = version);
$appState.pendingRequests.put(tauriEvent.payload);
});
listen('launch-terminal-request', async (tauriEvent) => {
if ($appState.currentRequest === null) {
let status = await invoke('get_session_status');
if (status === 'locked') {
navigate('Unlock');
}
else if (status === 'empty') {
navigate('EnterCredentials');
}
// else, session is unlocked, so do nothing
// (although we shouldn't even get the event in that case)
}
});
invoke('get_setup_errors') invoke('get_setup_errors')
.then(errs => { .then(errs => {
$appState.setupErrors = errs.map(e => ({msg: e, show: true})); $appState.setupErrors = errs.map(e => ({msg: e, show: true}));
}); });
// 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(); acceptRequest();
</script> </script>
<svelte:component this="{$currentView}" /> <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}

View File

@ -6,3 +6,12 @@ export function getRootCause(error) {
return error; 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

@ -30,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;
},
} }
} }

View File

@ -7,8 +7,9 @@ import { navigate, currentView, previousView } from './routing.js';
export let appState = writable({ export let appState = writable({
currentRequest: null, currentRequest: null,
pendingRequests: queue(), pendingRequests: queue(),
credentialStatus: 'locked', sessionStatus: 'locked',
setupErrors: [], setupErrors: [],
appVersion: '',
}); });
@ -23,12 +24,12 @@ export async function acceptRequest() {
} }
export function completeRequest() { export function cleanupRequest() {
currentView.set(get(previousView));
previousView.set(null);
appState.update($appState => { appState.update($appState => {
$appState.currentRequest = null; $appState.currentRequest = null;
return $appState; return $appState;
}); });
currentView.set(get(previousView));
previousView.set(null);
acceptRequest(); acceptRequest();
} }

View File

@ -5,3 +5,8 @@
.btn-alert-error { .btn-alert-error {
@apply bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content @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;
}

View File

@ -2,16 +2,42 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { fullMessage } from '../lib/errors.js';
let extraClasses = ""; let extraClasses = "";
export {extraClasses as class}; export {extraClasses as class};
export let slideDuration = 150; export let slideDuration = 150;
let animationClass = ""; let animationClass = "";
export function shake() { let error = null;
function shake() {
animationClass = 'shake'; animationClass = 'shake';
window.setTimeout(() => animationClass = "", 400); 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> </script>
@ -51,17 +77,17 @@
</style> </style>
<div in:slide="{{duration: slideDuration}}" class="alert alert-error shadow-lg {animationClass} {extraClasses}"> {#if error}
<div> <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> <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> <span>
<slot></slot> <slot {error}>{fullMessage(error)}</slot>
</span> </span>
</div>
{#if $$slots.buttons} {#if $$slots.buttons}
<div> <div>
<slot name="buttons"></slot> <slot name="buttons"></slot>
</div> </div>
{/if} {/if}
</div> </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>

View File

@ -5,7 +5,7 @@
let classes = ""; let classes = "";
export {classes as class}; export {classes as class};
let svg = ICONS[`./icons/${name}.svelte`].default; $: svg = ICONS[`./icons/${name}.svelte`].default;
</script> </script>
<svelte:component this={svg} class={classes} /> <svelte:component this={svg} class={classes} />

View File

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

View File

@ -21,16 +21,17 @@
throw(`Link target is not a string or a function: ${target}`) throw(`Link target is not a string or a function: ${target}`)
} }
} }
function handleHotkey(event) { function handleHotkey(event) {
if (!hotkey) return; if (
if (ctrl && !event.ctrlKey) return; hotkey === event.key
if (alt && !event.altKey) return; && ctrl === event.ctrlKey
if (shift && !event.shiftKey) return; && alt === event.altKey
&& shift === event.shiftKey
if (event.key === hotkey) { ) {
click(); click();
event.preventDefault();
} }
} }
</script> </script>

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>

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

@ -1,12 +1,19 @@
<script> <script>
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { open } from '@tauri-apps/api/dialog'; import { open } from '@tauri-apps/plugin-dialog';
import Setting from './Setting.svelte'; import Setting from './Setting.svelte';
export let title; export let title;
export let value; export let value;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
async function pickFile() {
let file = await open();
if (file) {
value = file.path
}
}
</script> </script>
@ -18,9 +25,10 @@
bind:value bind:value
on:change={() => dispatch('update', {value})} on:change={() => dispatch('update', {value})}
> >
<button <button
type="button"
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary"
on:click={async () => value = await open()} on:click={pickFile}
>Browse</button> >Browse</button>
</div> </div>
<slot name="description" slot="description"></slot> <slot name="description" slot="description"></slot>

View File

@ -1,13 +1,12 @@
<script> <script>
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import ErrorAlert from '../ErrorAlert.svelte';
export let title; export let title;
</script> </script>
<div> <div>
<div class="flex flex-wrap justify-between gap-y-4"> <div class="flex flex-wrap justify-between gap-4">
<h3 class="text-lg font-bold shrink-0">{title}</h3> <h3 class="text-lg font-bold shrink-0">{title}</h3>
{#if $$slots.input} {#if $$slots.input}
<slot name="input"></slot> <slot name="input"></slot>

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

@ -3,3 +3,4 @@ export { default as ToggleSetting } from './ToggleSetting.svelte';
export { default as NumericSetting } from './NumericSetting.svelte'; export { default as NumericSetting } from './NumericSetting.svelte';
export { default as FileSetting } from './FileSetting.svelte'; export { default as FileSetting } from './FileSetting.svelte';
export { default as TextSetting } from './TextSetting.svelte'; export { default as TextSetting } from './TextSetting.svelte';
export { default as TimeSetting } from './TimeSetting.svelte';

View File

@ -1,120 +1,64 @@
<script> <script>
import { onMount } from 'svelte'; import { appState, cleanupRequest } from '../lib/state.js';
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/core';
import { navigate } from '../lib/routing.js';
import { appState, completeRequest } from '../lib/state.js';
import ErrorAlert from '../ui/ErrorAlert.svelte'; import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte'; import CollectResponse from './approve/CollectResponse.svelte';
import KeyCombo from '../ui/KeyCombo.svelte'; import ShowResponse from './approve/ShowResponse.svelte';
import Unlock from './Unlock.svelte';
// Send response to backend, display error if applicable // Extra 50ms so the window can finish disappearing before the redraw
let error, alert; const rehideDelay = Math.min(5000, $appState.config.rehide_ms + 100);
async function respond() {
let {id, approval} = $appState.currentRequest; let alert;
let success = false;
async function sendResponse() {
try { try {
await invoke('respond', {response: {id, approval}}); await invoke('respond', {response: $appState.currentRequest.response});
navigate('ShowResponse'); success = true;
window.setTimeout(cleanupRequest, rehideDelay);
} }
catch (e) { catch (e) {
if (error) { // reset to null so that we go back to asking for approval
alert.shake(); $appState.currentRequest.response = null;
} // setTimeout forces this to not happen until the alert has been rendered
error = e; window.setTimeout(() => alert.setError(e), 0);
} }
} }
// Approval has one of several outcomes depending on current credential state async function handleResponseCollected() {
async function approve() { if (
$appState.currentRequest.approval = 'Approved'; $appState.sessionStatus === 'unlocked'
let status = await invoke('get_session_status'); || $appState.currentRequest.response.approval === 'Denied'
if (status === 'unlocked') { ) {
await respond(); await sendResponse();
}
else if (status === 'locked') {
navigate('Unlock');
}
else {
navigate('EnterCredentials');
} }
} }
// Denial has only one
async function deny() {
$appState.currentRequest.approval = 'Denied';
await respond();
}
// Extract executable name from full path
const client = $appState.currentRequest.client;
const m = client.exe?.match(/\/([^/]+?$)|\\([^\\]+?$)/);
const appName = m[1] || m[2];
// Executable paths can be long, so ensure they only break on \ or /
function breakPath(path) {
return path.replace(/(\\|\/)/g, '$1<wbr>');
}
// if the request has already been approved/denied, send response immediately
onMount(async () => {
if ($appState.currentRequest.approval) {
await respond();
}
})
</script> </script>
<!-- Don't render at all if we're just going to immediately proceed to the next screen --> {#if success}
{#if error || !$appState.currentRequest.approval} <!-- if we have successfully sent a response, show it -->
<ShowResponse />
{:else if !$appState.currentRequest?.response}
<!-- if a response hasn't been collected, ask for it -->
<div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center"> <div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center">
{#if error} <ErrorAlert bind:this={alert}>
<ErrorAlert bind:this={alert}> <svelte:fragment slot="buttons">
{error} <button class="btn btn-sm btn-alert-error" on:click={cleanupRequest}>Cancel</button>
<svelte:fragment slot="buttons"> <button class="btn btn-sm btn-alert-error" on:click={sendResponse}>Retry</button>
<button class="btn btn-sm btn-alert-error" on:click={completeRequest}>Cancel</button> </svelte:fragment>
<button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button> </ErrorAlert>
</svelte:fragment>
</ErrorAlert>
{/if}
{#if $appState.currentRequest.base} <CollectResponse on:response={handleResponseCollected} />
<div class="alert alert-warning shadow-lg">
<div>
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>
WARNING: This application is requesting your base (long-lived) AWS credentials.
These credentials are less secure than session credentials, since they don't expire automatically.
</span>
</div>
</div>
{/if}
<div class="space-y-1 mb-4">
<h2 class="text-xl font-bold">{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.</h2>
<div class="grid grid-cols-[auto_1fr] gap-x-3">
<div class="text-right">Path:</div>
<code class="">{@html client.exe ? breakPath(client.exe) : 'Unknown'}</code>
<div class="text-right">PID:</div>
<code>{client.pid}</code>
</div>
</div>
<div class="w-full flex justify-between">
<Link target={deny} hotkey="Escape">
<button class="btn btn-error justify-self-start">
<span class="mr-2">Deny</span>
<KeyCombo keys={['Esc']} />
</button>
</Link>
<Link target={approve} hotkey="Enter" shift="{true}">
<button class="btn btn-success justify-self-end">
<span class="mr-2">Approve</span>
<KeyCombo keys={['Shift', 'Enter']} />
</button>
</Link>
</div>
</div> </div>
{/if} {:else if $appState.sessionStatus === 'locked'}
<!-- if session is locked and we do have a response, we must be waiting for unlock -->
<Unlock on:unlocked={sendResponse} />
{:else}
<!-- failsafe sanity check -->
<ErrorAlert>
Something is wrong. This message should never show up during normal operation.
</ErrorAlert>
{/if}

View File

@ -0,0 +1,14 @@
<script>
import { navigate } from '../lib/routing.js';
import EnterPassphrase from './passphrase/EnterPassphrase.svelte';
</script>
<div class="flex flex-col h-screen max-w-sm m-auto gap-y-8 justify-center">
<h1 class="text-2xl font-bold text-center">
Change passphrase
</h1>
<EnterPassphrase cancellable={true} on:save={() => navigate('Home')}/>
</div>

View File

@ -0,0 +1,21 @@
<script>
import EnterPassphrase from './passphrase/EnterPassphrase.svelte';
</script>
<div class="flex flex-col h-screen max-w-lg m-auto justify-center">
<div class="space-y-8">
<h1 class="text-2xl font-bold text-center">Welcome to Creddy!</h1>
<div class="space-y-4">
<p> Create a passphrase to get started.</p>
<p>Please note that if you forget your passphrase, there is no way to recover
your stored credentials. You will have to start over with a new passphrase.</p>
</div>
<div class="max-w-sm mx-auto">
<EnterPassphrase />
</div>
</div>
</div>

View File

@ -1,92 +0,0 @@
<script>
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri';
import { emit } from '@tauri-apps/api/event';
import { getRootCause } from '../lib/errors.js';
import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js';
import Link from '../ui/Link.svelte';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Spinner from '../ui/Spinner.svelte';
let errorMsg = null;
let alert;
let AccessKeyId, SecretAccessKey, passphrase, confirmPassphrase
function confirm() {
if (passphrase !== confirmPassphrase) {
errorMsg = 'Passphrases do not match.'
}
}
let saving = false;
async function save() {
if (passphrase !== confirmPassphrase) {
alert.shake();
return;
}
let credentials = {AccessKeyId, SecretAccessKey};
try {
saving = true;
await invoke('save_credentials', {credentials, passphrase});
emit('credentials-event', 'entered');
if ($appState.currentRequest) {
navigate('Approve');
}
else {
navigate('Home');
}
}
catch (e) {
window.error = e;
const root = getRootCause(e);
if (e.code === 'GetSession' && root.code) {
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
}
else {
errorMsg = e.msg;
}
// if the alert already existed, shake it
if (alert) {
alert.shake();
}
saving = false;
}
}
function cancel() {
emit('credentials-event', 'enter-canceled');
navigate('Home');
}
</script>
<form action="#" on:submit|preventDefault="{save}" class="form-control space-y-4 max-w-sm m-auto p-4 h-screen justify-center">
<h2 class="text-2xl font-bold text-center">Enter your credentials</h2>
{#if errorMsg}
<ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert>
{/if}
<input type="text" placeholder="AWS Access Key ID" bind:value="{AccessKeyId}" class="input input-bordered" />
<input type="password" placeholder="AWS Secret Access Key" bind:value="{SecretAccessKey}" class="input input-bordered" />
<input type="password" placeholder="Passphrase" bind:value="{passphrase}" class="input input-bordered" />
<input type="password" placeholder="Re-enter passphrase" bind:value={confirmPassphrase} class="input input-bordered" on:change={confirm} />
<button type="submit" class="btn btn-primary">
{#if saving }
<Spinner class="w-5 h-5" thickness="12"/>
{:else}
Submit
{/if}
</button>
<Link target={cancel} hotkey="Escape">
<button class="btn btn-sm btn-outline w-full">Cancel</button>
</Link>
</form>

View File

@ -1,6 +1,6 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/core';
import { appState } from '../lib/state.js'; import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js'; import { navigate } from '../lib/routing.js';
@ -8,12 +8,24 @@
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import vaultDoorSvg from '../assets/vault_door.svg?raw'; let launchTerminalError;
async function launchTerminal() {
try {
await invoke('launch_terminal', {base: false});
}
catch (e) {
console.log(e);
launchTerminalError = e;
}
}
let launchBase = false; async function lock() {
function launchTerminal() { try {
invoke('launch_terminal', {base: launchBase}); await invoke('lock');
launchBase = false; }
catch (e) {
console.log(e);
}
} }
</script> </script>
@ -23,33 +35,42 @@
</Nav> </Nav>
<div class="flex flex-col h-screen items-center justify-center p-4 space-y-4"> <div class="flex flex-col h-screen items-center justify-center p-4 space-y-4">
<div class="flex flex-col items-center space-y-4"> <div class="grid grid-cols-2 gap-6">
{@html vaultDoorSvg} <button
{#await invoke('get_session_status') then status} on:click={() => navigate('ManageCredentials')}
{#if status === 'locked'} class="flex flex-col items-center gap-4 h-full max-w-56 rounded-box p-4 border border-primary hover:bg-base-200 transition-transform active:scale-[.98] transition-transform"
>
<Icon name="key" class="size-12 stroke-1 stroke-primary" />
<h3 class="text-lg font-bold">Credentials</h3>
<p class="text-sm">Add, remove, and change default credentials.</p>
</button>
<button
on:click={launchTerminal}
class="flex flex-col items-center gap-4 h-full max-w-56 rounded-box p-4 border border-secondary hover:bg-base-200 transition-colors active:scale-[.98] transition-transform"
>
<Icon name="command-line" class="size-12 stroke-1 stroke-secondary" />
<h3 class="text-lg font-bold">Terminal</h3>
<p class="text-sm">Launch a terminal pre-configured with AWS credentials.</p>
</button>
<h2 class="text-2xl font-bold">Creddy is locked</h2> <button
<Link target="Unlock" hotkey="Enter" class="w-64"> on:click={lock}
<button class="btn btn-primary w-full">Unlock</button> class="flex flex-col items-center gap-4 h-full max-w-56 rounded-box p-4 border border-warning hover:bg-base-200 transition-colors active:scale-[.98] transition-transform"
</Link> >
<Icon name="shield-check" class="size-12 stroke-1 stroke-warning" />
<h3 class="text-lg font-bold">Lock</h3>
<p class="text-sm">Lock Creddy.</p>
</button>
{:else if status === 'unlocked'} <button
<h2 class="text-2xl font-bold">Waiting for requests</h2> on:click={() => invoke('exit')}
<button class="btn btn-primary w-full" on:click={launchTerminal}> class="flex flex-col items-center gap-4 h-full max-w-56 rounded-box p-4 border border-accent hover:bg-base-200 transition-colors active:scale-[.98] transition-transform"
Launch Terminal >
</button> <Icon name="arrow-right-start-on-rectangle" class="size-12 stroke-1 stroke-accent" />
<label class="label cursor-pointer flex items-center space-x-2"> <h3 class="text-lg font-bold">Exit</h3>
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}> <p class="text-sm">Close Creddy.</p>
<span class="label-text">Launch with base credentials</span> </button>
</label>
{:else if status === 'empty'}
<h2 class="text-2xl font-bold">No credentials found</h2>
<Link target="EnterCredentials" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Enter Credentials</button>
</Link>
{/if}
{/await}
</div> </div>
</div> </div>
@ -58,10 +79,25 @@
{#each $appState.setupErrors as error} {#each $appState.setupErrors as error}
{#if error.show} {#if error.show}
<div class="alert alert-error shadow-lg"> <div class="alert alert-error shadow-lg">
{error.msg} <span>{error.msg}</span>
<button class="btn btn-sm btn-alert-error" on:click={() => error.show = false}>Ok</button> <div>
<button class="btn btn-sm btn-alert-error" on:click={() => error.show = false}>Ok</button>
</div>
</div> </div>
{/if} {/if}
{/each} {/each}
</div> </div>
{/if}
{#if launchTerminalError}
<div class="toast">
<div class="alert alert-error shadow-lg">
<span>{launchTerminalError.msg || launchTerminalError}</span>
<div>
<button class="btn btn-alert-error" on:click={() => launchTerminalError = null}>
Ok
</button>
</div>
</div>
</div>
{/if} {/if}

View File

@ -0,0 +1,122 @@
<script>
import { onMount } from 'svelte';
import { slide, fade } from 'svelte/transition';
import { writable } from 'svelte/store';
import { invoke } from '@tauri-apps/api/core';
import AwsCredential from './credentials/AwsCredential.svelte';
import ConfirmDelete from './credentials/ConfirmDelete.svelte';
import SshKey from './credentials/SshKey.svelte';
// import NewSshKey from './credentials/NewSshKey.svelte';
// import EditSshKey from './credentials/EditSshKey.svelte';
import Icon from '../ui/Icon.svelte';
import Nav from '../ui/Nav.svelte';
let records = null
$: awsRecords = (records || []).filter(r => r.credential.type === 'AwsBase');
$: sshRecords = (records || []).filter(r => r.credential.type === 'Ssh');
let defaults = writable({});
async function loadCreds() {
records = await invoke('list_credentials');
let pairs = records.filter(r => r.is_default).map(r => [r.credential.type, r.id]);
$defaults = Object.fromEntries(pairs);
}
onMount(loadCreds);
function newAws() {
records.push({
id: crypto.randomUUID(),
name: null,
is_default: false,
credential: {type: 'AwsBase', AccessKeyId: '', SecretAccessKey: ''},
isNew: true,
});
records = records;
}
function newSsh() {
records.push({
id: crypto.randomUUID(),
name: null,
is_default: false,
credential: {type: 'Ssh', algorithm: '', comment: '', private_key: '', public_key: '',},
isNew: true,
});
records = records;
}
let confirmDelete;
function handleDelete(evt) {
const record = evt.detail;
if (record.isNew) {
records = records.filter(r => r.id !== record.id);
}
else {
confirmDelete.confirm(record);
}
}
</script>
<Nav>
<h1 slot="title" class="text-2xl font-bold">Credentials</h1>
</Nav>
<div class="max-w-xl mx-auto mb-12 flex flex-col gap-y-12 justify-center">
<div class="flex flex-col gap-y-4">
<div class="divider">
<h2 class="text-xl font-bold">AWS Access Keys</h2>
</div>
{#if awsRecords.length > 0}
{#each awsRecords as record (record.id)}
<AwsCredential
{record} {defaults}
on:update={loadCreds}
on:delete={handleDelete}
/>
{/each}
<button class="btn btn-primary btn-wide mx-auto" on:click={newAws}>
<Icon name="plus-circle-mini" class="size-5" />
Add
</button>
{:else if records !== null}
<div class="flex flex-col gap-6 items-center rounded-box border-2 border-dashed border-neutral-content/30 p-6">
<div>You have no saved AWS credentials.</div>
<button class="btn btn-primary btn-wide mx-auto" on:click={newAws}>
<Icon name="plus-circle-mini" class="size-5" />
Add
</button>
</div>
{/if}
</div>
<div class="flex flex-col gap-y-4">
<div class="divider">
<h2 class="text-xl font-bold">SSH Keys</h2>
</div>
{#if sshRecords.length > 0}
{#each sshRecords as record (record.id)}
<SshKey {record} on:save={loadCreds} on:delete={handleDelete} />
{/each}
<button class="btn btn-primary btn-wide mx-auto" on:click={newSsh}>
<Icon name="plus-circle-mini" class="size-5" />
Add
</button>
{:else if records !== null}
<div class="flex flex-col gap-6 items-center rounded-box border-2 border-dashed border-neutral-content/30 p-6">
<div>You have no saved SSH keys.</div>
<button class="btn btn-primary btn-wide mx-auto" on:click={newSsh}>
<Icon name="plus-circle-mini" class="size-5" />
Add
</button>
</div>
{/if}
</div>
</div>
<ConfirmDelete bind:this={confirmDelete} on:confirm={loadCreds} />

View File

@ -1,14 +1,13 @@
<script> <script>
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/core';
import { type } from '@tauri-apps/api/os'; import { type } from '@tauri-apps/plugin-os';
import { appState } from '../lib/state.js'; import { appState } from '../lib/state.js';
import Nav from '../ui/Nav.svelte'; import Nav from '../ui/Nav.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import SettingsGroup from '../ui/settings/SettingsGroup.svelte'; import SettingsGroup from '../ui/settings/SettingsGroup.svelte';
import Keybind from '../ui/settings/Keybind.svelte'; import Keybind from '../ui/settings/Keybind.svelte';
import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting } from '../ui/settings'; import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting, TimeSetting } from '../ui/settings';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { backInOut } from 'svelte/easing'; import { backInOut } from 'svelte/easing';
@ -21,6 +20,7 @@
let error = null; let error = null;
async function save() { async function save() {
try { try {
throw('wtf');
await invoke('save_config', {config}); await invoke('save_config', {config});
$appState.config = await invoke('get_config'); $appState.config = await invoke('get_config');
} }
@ -29,6 +29,7 @@
} }
} }
window.getOsType = type;
let osType = null; let osType = null;
type().then(t => osType = t); type().then(t => osType = t);
</script> </script>
@ -38,60 +39,78 @@
<h1 slot="title" class="text-2xl font-bold">Settings</h1> <h1 slot="title" class="text-2xl font-bold">Settings</h1>
</Nav> </Nav>
<div class="max-w-lg mx-auto mt-1.5 mb-24 p-4 space-y-16"> <form on:submit|preventDefault={save}>
<SettingsGroup name="General"> <div class="max-w-lg mx-auto my-1.5 p-4 space-y-16">
<ToggleSetting title="Start on login" bind:value={config.start_on_login}> <SettingsGroup name="General">
<svelte:fragment slot="description"> <ToggleSetting title="Start on login" bind:value={config.start_on_login}>
Start Creddy when you log in to your computer. <svelte:fragment slot="description">
</svelte:fragment> Start Creddy when you log in to your computer.
</ToggleSetting> </svelte:fragment>
</ToggleSetting>
<ToggleSetting title="Start minimized" bind:value={config.start_minimized}> <ToggleSetting title="Start minimized" bind:value={config.start_minimized}>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Minimize to the system tray at startup. Minimize to the system tray at startup.
</svelte:fragment> </svelte:fragment>
</ToggleSetting> </ToggleSetting>
<NumericSetting title="Re-hide delay" bind:value={config.rehide_ms} min={0} unit="Milliseconds"> <NumericSetting title="Re-hide delay" bind:value={config.rehide_ms} min={0} unit="Milliseconds">
<svelte:fragment slot="description"> <svelte:fragment slot="description">
How long to wait after a request is approved/denied before minimizing How long to wait after a request is approved/denied before minimizing
the window to tray. Only applicable if the window was minimized the window to tray. Only applicable if the window was minimized
to tray before the request was received. to tray before the request was received.
</svelte:fragment> </svelte:fragment>
</NumericSetting> </NumericSetting>
<Setting title="Update credentials"> <ToggleSetting title="Lock when idle" bind:value={config.auto_lock}>
<Link slot="input" target="EnterCredentials"> <svelte:fragment slot="description">
<button class="btn btn-sm btn-primary">Update</button> Automatically lock Creddy after a period of inactivity.
</Link> </svelte:fragment>
<svelte:fragment slot="description"> </ToggleSetting>
Update or re-enter your encrypted credentials.
</svelte:fragment>
</Setting>
<FileSetting {#if config.auto_lock}
title="Terminal emulator" <TimeSetting title="Idle timeout" bind:seconds={config.lock_after.secs}>
bind:value={config.terminal.exec} <svelte:fragment slot="description">
How long to wait before automatically locking.
> </svelte:fragment>
<svelte:fragment slot="description"> </TimeSetting>
Choose your preferred terminal emulator (e.g. <code>gnome-terminal</code> or <code>wt.exe</code>.) May be an absolute path or an executable discoverable on <code>$PATH</code>. {/if}
</svelte:fragment>
</FileSetting>
</SettingsGroup>
<SettingsGroup name="Hotkeys"> <Setting title="Update passphrase">
<div class="space-y-4"> <Link slot="input" target="ChangePassphrase">
<p>Click on a keybinding to modify it. Use the checkbox to enable or disable a keybinding entirely.</p> <button type="button" class="btn btn-sm btn-primary">Update</button>
</Link>
<svelte:fragment slot="description">
Change your master passphrase.
</svelte:fragment>
</Setting>
<div class="grid grid-cols-[auto_1fr_auto] gap-y-3 items-center"> <FileSetting
<Keybind description="Show Creddy" bind:value={config.hotkeys.show_window} /> title="Terminal emulator"
<Keybind description="Launch terminal" bind:value={config.hotkeys.launch_terminal} /> bind:value={config.terminal.exec}
>
<svelte:fragment slot="description">
Choose your preferred terminal emulator (e.g. <code>gnome-terminal</code> or <code>wt.exe</code>.) May be an absolute path or an executable discoverable on <code>$PATH</code>.
</svelte:fragment>
</FileSetting>
</SettingsGroup>
<SettingsGroup name="Hotkeys">
<div class="space-y-4">
<p>Click on a keybinding to modify it. Use the checkbox to enable or disable a keybinding entirely.</p>
<div class="grid grid-cols-[auto_1fr_auto] gap-y-3 items-center">
<Keybind description="Show Creddy" bind:value={config.hotkeys.show_window} />
<Keybind description="Launch terminal" bind:value={config.hotkeys.launch_terminal} />
</div>
</div> </div>
</div> </SettingsGroup>
</SettingsGroup>
</div> <p class="text-sm text-right">
Creddy {$appState.appVersion}
</p>
</div>
</form>
{#if error} {#if error}
<div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast"> <div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast">
@ -112,7 +131,7 @@
<div> <div>
<!-- <button class="btn btn-sm btn-ghost">Cancel</button> --> <!-- <button class="btn btn-sm btn-ghost">Cancel</button> -->
<buton class="btn btn-sm btn-primary" on:click={save}>Save</buton> <button class="btn btn-sm btn-primary" on:click={save}>Save</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,78 +1,68 @@
<script> <script>
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/core';
import { emit } from '@tauri-apps/api/event'; import { emit } from '@tauri-apps/api/event';
import { onMount } from 'svelte'; import { onMount, createEventDispatcher } from 'svelte';
import { appState } from '../lib/state.js'; import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js'; import { navigate } from '../lib/routing.js';
import { getRootCause } from '../lib/errors.js'; import { getRootCause } from '../lib/errors.js';
import ErrorAlert from '../ui/ErrorAlert.svelte'; import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import PassphraseInput from '../ui/PassphraseInput.svelte';
import ResetPassphrase from './passphrase/ResetPassphrase.svelte';
import Spinner from '../ui/Spinner.svelte'; import Spinner from '../ui/Spinner.svelte';
import vaultDoorSvg from '../assets/vault_door.svg?raw';
let errorMsg = null; const dispatch = createEventDispatcher();
let alert; let alert;
let passphrase = ''; let passphrase = '';
let loadTime = 0;
let saving = false; let saving = false;
async function unlock() { async function unlock() {
// The hotkey for navigating here from homepage is Enter, which also saving = true;
// happens to trigger the form submit event
if (Date.now() - loadTime < 10) {
return;
}
try { try {
saving = true; await alert.run(async () => invoke('unlock', {passphrase}));
let r = await invoke('unlock', {passphrase}); $appState.sessionStatus = 'unlocked';
$appState.credentialStatus = 'unlocked'; emit('unlocked');
emit('credentials-event', 'unlocked'); dispatch('unlocked');
if ($appState.currentRequest) {
navigate('Approve');
}
else {
navigate('Home');
}
} }
catch (e) { finally {
const root = getRootCause(e);
if (e.code === 'GetSession' && root.code) {
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
}
else {
errorMsg = e.msg;
}
// if the alert already existed, shake it
if (alert) {
alert.shake();
}
saving = false; saving = false;
} }
} }
function cancel() { let input;
emit('credentials-event', 'unlock-canceled'); onMount(() => input.focus());
navigate('Home');
}
onMount(() => {
loadTime = Date.now();
})
</script> </script>
<svelte:window on:focus={input.focus} />
<div class="fixed top-0 w-full p-2 text-center">
<h1 class="text-3xl font-bold">Creddy is locked</h1>
</div>
<form action="#" on:submit|preventDefault="{unlock}" class="form-control space-y-4 max-w-sm m-auto p-4 h-screen justify-center"> <form action="#" on:submit|preventDefault="{unlock}" class="form-control space-y-4 max-w-sm m-auto p-4 h-screen justify-center">
<h2 class="font-bold text-2xl text-center">Enter your passphrase</h2> <div class="mx-auto">
{@html vaultDoorSvg}
</div>
{#if errorMsg} <label class="space-y-4">
<ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert> <h2 class="font-bold text-xl text-center">Please enter your passphrase</h2>
{/if}
<!-- svelte-ignore a11y-autofocus --> <ErrorAlert bind:this="{alert}" />
<input autofocus name="password" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" class="input input-bordered" />
<!-- svelte-ignore a11y-autofocus -->
<PassphraseInput
bind:this={input}
bind:value={passphrase}
placeholder="correct horse battery staple"
/>
</label>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
{#if saving} {#if saving}
@ -82,7 +72,5 @@
{/if} {/if}
</button> </button>
<Link target={cancel} hotkey="Escape"> <ResetPassphrase />
<button class="btn btn-sm btn-outline w-full">Cancel</button>
</Link>
</form> </form>

View File

@ -0,0 +1,107 @@
<script>
import { createEventDispatcher } from 'svelte';
import { appState, cleanupRequest } from '../../lib/state.js';
import Link from '../../ui/Link.svelte';
import KeyCombo from '../../ui/KeyCombo.svelte';
// Executable paths can be long, so ensure they only break on \ or /
function breakPath(path) {
return path.replace(/(\\|\/)/g, '$1<wbr>');
}
// Extract executable name from full path
const client = $appState.currentRequest.client;
const m = client.exe?.match(/\/([^/]+?$)|\\([^\\]+?$)/);
const appName = m[1] || m[2];
const dispatch = createEventDispatcher();
function setResponse(approval, base) {
$appState.currentRequest.response = {
id: $appState.currentRequest.id,
approval,
base,
};
dispatch('response');
}
</script>
{#if $appState.currentRequest?.base}
<div class="alert alert-warning shadow-lg">
<div>
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>
WARNING: This application is requesting your base AWS credentials.
These credentials are less secure than session credentials, since they don't expire automatically.
</span>
</div>
</div>
{/if}
<div class="space-y-1 mb-4">
<h2 class="text-xl font-bold">
{#if $appState.currentRequest.type === 'Aws'}
{#if $appState.currentRequest.name}
{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS access key "{$appState.currentRequest.name}".
{:else}
{appName ? `"${appName}"` : 'An appplication'} would like to access your default AWS access key
{/if}
{:else if $appState.currentRequest.type === 'Ssh'}
{appName ? `"${appName}"` : 'An application'} would like to use your SSH key "{$appState.currentRequest.key_name}".
{/if}
</h2>
<div class="grid grid-cols-[auto_1fr] gap-x-3">
<div class="text-right">Path:</div>
<code class="">{@html client.exe ? breakPath(client.exe) : 'Unknown'}</code>
<div class="text-right">PID:</div>
<code>{client.pid}</code>
</div>
</div>
<div class="w-full grid grid-cols-[1fr_auto] items-center gap-y-6">
<!-- Don't display the option to approve with session credentials if base was specifically requested -->
{#if !$appState.currentRequest?.base}
<h3 class="font-semibold">
{#if $appState.currentRequest.type === 'Aws'}
Approve with session credentials
{:else}
Approve
{/if}
</h3>
<Link target={() => setResponse('Approved', false)} hotkey="Enter" shift={true}>
<button class="w-full btn btn-success">
<KeyCombo keys={['Shift', 'Enter']} />
</button>
</Link>
{/if}
{#if $appState.currentRequest.type === 'Aws'}
<h3 class="font-semibold">
<span class="mr-2">
{#if $appState.currentRequest?.base}
Approve
{:else}
Approve with base credentials
{/if}
</span>
</h3>
<Link target={() => setResponse('Approved', true)} hotkey="Enter" shift={true} ctrl={true}>
<button class="w-full btn btn-warning">
<KeyCombo keys={['Ctrl', 'Shift', 'Enter']} />
</button>
</Link>
{/if}
<h3 class="font-semibold">
<span class="mr-2">Deny</span>
</h3>
<Link target={() => setResponse('Denied', false)} hotkey="Escape">
<button class="w-full btn btn-error">
<KeyCombo keys={['Esc']} />
</button>
</Link>
</div>

View File

@ -1,8 +1,7 @@
<script> <script>
import { onMount } from 'svelte';
import { draw, fade } from 'svelte/transition'; import { draw, fade } from 'svelte/transition';
import { appState, completeRequest } from '../lib/state.js'; import { appState } from '../../lib/state.js';
let success = false; let success = false;
let error = null; let error = null;
@ -10,19 +9,11 @@
let drawDuration = $appState.config.rehide_ms >= 750 ? 500 : 0; let drawDuration = $appState.config.rehide_ms >= 750 ? 500 : 0;
let fadeDuration = drawDuration * 0.6; let fadeDuration = drawDuration * 0.6;
let fadeDelay = drawDuration * 0.4; let fadeDelay = drawDuration * 0.4;
onMount(() => {
window.setTimeout(
completeRequest,
// Extra 50ms so the window can finish disappearing before the redraw
Math.min(5000, $appState.config.rehide_ms + 50),
)
})
</script> </script>
<div class="flex flex-col h-screen items-center justify-center max-w-max m-auto"> <div class="flex flex-col h-screen items-center justify-center max-w-max m-auto">
{#if $appState.currentRequest.approval === 'Approved'} {#if $appState.currentRequest.response.approval === 'Approved'}
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path in:draw="{{duration: drawDuration}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path in:draw="{{duration: drawDuration}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
@ -33,6 +24,6 @@
{/if} {/if}
<div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold"> <div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold">
{$appState.currentRequest.approval}! {$appState.currentRequest.response.approval}!
</div> </div>
</div> </div>

View File

@ -0,0 +1,119 @@
<script>
import { createEventDispatcher } from 'svelte';
import { fade, slide } from 'svelte/transition';
import { invoke } from '@tauri-apps/api/core';
import ErrorAlert from '../../ui/ErrorAlert.svelte';
import Icon from '../../ui/Icon.svelte';
export let record;
export let defaults;
import PassphraseInput from '../../ui/PassphraseInput.svelte';
const dispatch = createEventDispatcher();
let showDetails = record.isNew ? true : false;
let local = JSON.parse(JSON.stringify(record));
$: isModified = JSON.stringify(local) !== JSON.stringify(record);
// explicitly subscribe to updates to `default`, so that we can update
// our local copy even if the component hasn't been recreated
// (sadly we can't use a reactive binding because reasons I guess)
defaults.subscribe(d => local.is_default = local.id === d[local.credential.type])
let alert;
async function saveCredential() {
await invoke('save_credential', {record: local});
dispatch('update');
showDetails = false;
}
</script>
<div class="rounded-box space-y-4 bg-base-200 {record.is_default ? 'border border-accent' : ''}">
<div class="flex items-center px-6 py-4 gap-x-4">
<h3 class="text-lg font-bold">
{#if !record?.isNew && showDetails}
<input type="text" class="input input-bordered bg-transparent" bind:value={local.name}>
{:else}
{record.name || ''}
{/if}
</h3>
{#if record.is_default}
<span class="badge badge-accent">Default</span>
{/if}
<div class="join ml-auto">
<button
type="button"
class="btn btn-outline join-item"
on:click={() => showDetails = !showDetails}
>
<Icon name="pencil" class="size-6" />
</button>
<button
type="button"
class="btn btn-outline btn-error join-item"
on:click={() => dispatch('delete', record)}
>
<Icon name="trash" class="size-6" />
</button>
</div>
</div>
{#if showDetails}
<form
transition:slide|local={{duration: 200}}
class=" px-6 pb-4 space-y-4"
on:submit|preventDefault={() => alert.run(saveCredential)}
>
<ErrorAlert bind:this={alert} />
<div class="grid grid-cols-[auto_1fr] items-center gap-4">
{#if record.isNew}
<span class="justify-self-end">Name</span>
<input
type="text"
class="input input-bordered bg-transparent"
bind:value={local.name}
>
{/if}
<span class="justify-self-end">Key ID</span>
<input
type="text"
class="input input-bordered font-mono bg-transparent"
bind:value={local.credential.AccessKeyId}
>
<span>Secret key</span>
<div class="font-mono">
<PassphraseInput class="bg-transparent" bind:value={local.credential.SecretAccessKey} />
</div>
</div>
<div class="flex justify-between">
<label class="label cursor-pointer justify-self-start space-x-4">
<span class="label-text">Default AWS access key</span>
<input type="checkbox" class="toggle toggle-accent" bind:checked={local.is_default}>
</label>
{#if isModified}
<button
transition:fade={{duration: 100}}
type="submit"
class="btn btn-primary"
>
Save
</button>
{/if}
</div>
</form>
{/if}
</div>

View File

@ -0,0 +1,62 @@
<script>
import { invoke } from '@tauri-apps/api/core';
import { createEventDispatcher } from 'svelte';
import ErrorAlert from '../../ui/ErrorAlert.svelte';
let record;
let modal;
let alert;
const dispatch = createEventDispatcher();
export function confirm(r) {
record = r;
modal.showModal();
}
async function deleteCredential() {
await invoke('delete_credential', {id: record.id})
// closing the modal is dependent on the previous step succeeding
modal.close();
dispatch('confirm');
}
function credentialDescription(record) {
if (record.credential.type === 'AwsBase') {
return 'AWS credential';
}
if (record.credential.type === 'Ssh') {
return 'SSH key';
}
}
</script>
<dialog bind:this={modal} class="modal">
<div class="modal-box space-y-6">
<ErrorAlert bind:this={alert} />
<h3 class="text-lg font-bold">
{#if record}
Delete {credentialDescription(record)} "{record.name}"?
{/if}
</h3>
<div class="modal-action">
<form method="dialog" class="flex gap-x-4">
<button
class="btn btn-outline"
on:click={() => alert.setError(null)}
>
Cancel
</button>
<button
autofocus
class="btn btn-error"
on:click|preventDefault={() => alert.run(deleteCredential)}
>
Delete
</button>
</form>
</div>
</div>
</dialog>

View File

@ -0,0 +1,84 @@
<script>
import { invoke } from '@tauri-apps/api/core';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import ErrorAlert from '../../ui/ErrorAlert.svelte';
export let local;
export let isModified;
const dispatch = createEventDispatcher();
let alert;
async function saveCredential() {
await invoke('save_credential', {record: local});
dispatch('save', local);
}
async function copyText(evt) {
const tooltip = event.currentTarget;
await navigator.clipboard.writeText(tooltip.dataset.copyText);
const prevText = tooltip.dataset.tip;
tooltip.dataset.tip = 'Copied!';
window.setTimeout(() => tooltip.dataset.tip = prevText, 2000);
}
</script>
<style>
.grid {
grid-template-columns: auto minmax(0, 1fr);
}
</style>
<form class="space-y-4" on:submit|preventDefault={() => alert.run(saveCredential)}>
<ErrorAlert bind:this={alert} />
<div class="grid items-baseline gap-4">
<span class="justify-self-end">Comment</span>
<input
type="text"
class="input input-bordered bg-transparent"
bind:value={local.credential.comment}
>
<span class="justify-self-end">Public key</span>
<div
class="tooltip tooltip-right"
data-tip="Click to copy"
data-copy-text={local.credential.public_key}
on:click={copyText}
>
<div class="cursor-pointer text-left textarea textarea-bordered bg-transparent font-mono break-all">
{local.credential.public_key}
</div>
</div>
<span class="justify-self-end">Private key</span>
<div
class="tooltip tooltip-right"
data-tip="Click to copy"
data-copy-text={local.credential.private_key}
on:click={copyText}
>
<div class="cursor-pointer text-left textarea textarea-bordered bg-transparent font-mono whitespace-pre overflow-x-auto">
{local.credential.private_key}
</div>
</div>
</div>
<div class="flex justify-end">
{#if isModified}
<button
transition:fade={{duration: 100}}
type="submit"
class="btn btn-primary"
>
Save
</button>
{/if}
</div>
</form>

View File

@ -0,0 +1,119 @@
<script>
import { createEventDispatcher } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { homeDir } from '@tauri-apps/api/path';
import { fade } from 'svelte/transition';
import ErrorAlert from '../../ui/ErrorAlert.svelte';
import FileInput from '../../ui/FileInput.svelte';
import PassphraseInput from '../../ui/PassphraseInput.svelte';
import Spinner from '../../ui/Spinner.svelte';
export let record;
let name;
let file;
let privateKey = '';
let passphrase = '';
let showDetails = true;
let mode = 'file';
const dispatch = createEventDispatcher();
let defaultPath = null;
homeDir().then(d => defaultPath = `${d}/.ssh`);
let alert;
let saving = false;
async function saveCredential() {
saving = true;
try {
let key = await getKey();
const payload = {
id: record.id,
name,
is_default: false, // ssh keys don't care about defaults
credential: {type: 'Ssh', ...key},
};
await invoke('save_credential', {record: payload});
dispatch('save', payload);
}
finally {
saving = false;
}
}
async function getKey() {
if (mode === 'file') {
return await invoke('sshkey_from_file', {path: file.path, passphrase});
}
else {
return await invoke('sshkey_from_private_key', {privateKey, passphrase});
}
}
</script>
<div role="tablist" class="join max-w-sm mx-auto flex justify-center">
<button
type="button"
role="tab"
class="join-item flex-1 btn border border-primary hover:border-primary"
class:btn-primary={mode === 'file'}
on:click={() => mode = 'file'}
>
From file
</button>
<button
type="button"
role="tab"
class="join-item flex-1 btn border border-primary hover:border-primary"
class:btn-primary={mode === 'direct'}
on:click={() => mode = 'direct'}
>
From private key
</button>
</div>
<form class="space-y-4" on:submit|preventDefault={alert.run(saveCredential)}>
<ErrorAlert bind:this={alert} />
<div class="grid grid-cols-[auto_1fr] items-center gap-4">
<span class="justify-self-end">Name</span>
<input
type="text"
class="input input-bordered bg-transparent"
bind:value={name}
>
{#if mode === 'file'}
<span class="justify-self-end">File</span>
<FileInput params={{defaultPath}} bind:value={file} on:update={() => name = file.name} />
{:else}
<span class="justify-self-end">Private key</span>
<textarea bind:value={privateKey} rows="5" class="textarea textarea-bordered bg-transparent font-mono whitespace-pre overflow-x-auto"></textarea>
{/if}
<span class="justify-self-end">Passphrase</span>
<PassphraseInput class="bg-transparent" bind:value={passphrase} />
</div>
<div class="flex justify-end">
{#if file?.path || privateKey !== ''}
<button
transition:fade={{duration: 100}}
type="submit"
class="btn btn-primary"
>
{#if saving}
<Spinner class="size-5 min-w-16" thickness="12" />
{:else}
<span class="min-w-16">Save</span>
{/if}
</button>
{/if}
</div>
</form>

View File

@ -0,0 +1,71 @@
<script>
import { createEventDispatcher } from 'svelte';
import { slide } from 'svelte/transition';
import NewSshKey from './NewSshKey.svelte';
import EditSshKey from './EditSshKey.svelte';
import Icon from '../../ui/Icon.svelte';
export let record;
const dispatch = createEventDispatcher();
function copy(obj) {
return JSON.parse(JSON.stringify(obj));
}
let local = copy(record);
$: isModified = JSON.stringify(local) !== JSON.stringify(record);
let showDetails = record?.isNew;
function handleSave(evt) {
local = copy(evt.detail);
showDetails = false;
}
</script>
<div class="rounded-box space-y-4 bg-base-200">
<div class="flex items-center px-6 py-4 gap-x-4">
{#if !record.isNew}
{#if showDetails}
<input
type="text"
class="input input-bordered bg-transparent text-lg font-bold"
bind:value={local.name}
>
{:else}
<h3 class="text-lg font-bold">
{record.name}
</h3>
{/if}
{/if}
<div class="join ml-auto">
<button
type="button"
class="btn btn-outline join-item"
on:click={() => showDetails = !showDetails}
>
<Icon name="pencil" class="size-6" />
</button>
<button
type="button"
class="btn btn-outline btn-error join-item"
on:click={() => dispatch('delete', record)}
>
<Icon name="trash" class="size-6" />
</button>
</div>
</div>
{#if record && showDetails}
<div transition:slide|local={{duration: 200}} class="px-6 pb-4 space-y-4">
{#if record.isNew}
<NewSshKey {record} on:save on:save={handleSave} />
{:else}
<EditSshKey bind:local={local} {isModified} on:save={handleSave} on:save />
{/if}
</div>
{/if}
</div>

View File

@ -0,0 +1,109 @@
<script>
import { createEventDispatcher } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { appState } from '../../lib/state.js';
import ErrorAlert from '../../ui/ErrorAlert.svelte';
import Link from '../../ui/Link.svelte';
import PassphraseInput from '../../ui/PassphraseInput.svelte';
import ResetPassphrase from './ResetPassphrase.svelte';
import Spinner from '../../ui/Spinner.svelte';
export let cancellable = false;
const dispatch = createEventDispatcher();
let alert;
let saving = false;
let passphrase = '';
let confirmPassphrase = '';
// onChange only fires when an input loses focus, so always set the error if not set
function onChange() {
console.log(`onChange: passphrase=${passphrase}, confirmPassphrase=${confirmPassphrase}`)
if (passphrase !== confirmPassphrase) {
alert.setError('Passphrases do not match.');
}
else {
alert.setError(null);
}
}
// onInput fires on every keystroke, so only dismiss the error, don't create it
function onInput() {
console.log(`onInput: passphrase=${passphrase}, confirmPassphrase=${confirmPassphrase}`)
if (passphrase === confirmPassphrase) {
alert.setError(null);
}
}
async function save() {
if (passphrase !== confirmPassphrase) {
return;
}
if (passphrase === '') {
alert.setError('Passphrase is empty.')
return;
}
saving = true;
try {
await alert.run(async () => {
await invoke('set_passphrase', {passphrase})
throw('something bad happened');
$appState.sessionStatus = 'unlocked';
dispatch('save');
});
}
finally {
saving = false;
}
}
</script>
<form class="form-control gap-y-4" on:submit|preventDefault={save}>
<ErrorAlert bind:this={alert} />
<label class="form-control w-full">
<div class="label">
<span class="label-text">Passphrase</span>
</div>
<PassphraseInput
bind:value={passphrase}
on:input={onInput}
placeholder="correct horse battery staple"
/>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Re-enter passphrase</span>
</div>
<PassphraseInput
bind:value={confirmPassphrase}
on:input={onInput} on:change={onChange}
placeholder="correct horse battery staple"
/>
</label>
<button type="submit" class="btn btn-primary">
{#if saving}
<Spinner class="w-5 h-5" thickness="12"/>
{:else}
Submit
{/if}
</button>
{#if cancellable}
<Link target="Settings" hotkey="Escape">
<button type="button" class="btn btn-outline btn-sm w-full">Cancel</button>
</Link>
{/if}
{#if $appState.sessionStatus === 'locked'}
<ResetPassphrase />
{/if}
</form>

Some files were not shown because too many files have changed in this diff Show More