9 Commits

30 changed files with 1209 additions and 521 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "creddy", "name": "creddy",
"version": "0.5.3", "version": "0.5.4",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",

176
src-tauri/Cargo.lock generated
View File

@ -110,6 +110,55 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anstream"
version = "0.6.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
[[package]]
name = "anstyle-parse"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19"
dependencies = [
"anstyle",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.86" version = "1.0.86"
@ -327,17 +376,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi 0.1.19",
"libc",
"winapi",
]
[[package]] [[package]]
name = "auto-launch" name = "auto-launch"
version = "0.4.0" version = "0.4.0"
@ -1023,42 +1061,43 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "3.2.25" version = "4.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" checksum = "64acc1846d54c1fe936a78dc189c34e28d3f5afc348403f28ecf53660b9b8462"
dependencies = [ dependencies = [
"atty", "clap_builder",
"bitflags 1.3.2",
"clap_derive", "clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fb8393d67ba2e7bfaf28a23458e4e2b543cc73a99595511eb207fdb8aede942"
dependencies = [
"anstream",
"anstyle",
"clap_lex", "clap_lex",
"indexmap 1.9.3", "strsim",
"once_cell",
"strsim 0.10.0",
"termcolor",
"textwrap",
] ]
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "3.2.25" version = "4.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008" checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085"
dependencies = [ dependencies = [
"heck 0.4.1", "heck 0.5.0",
"proc-macro-error",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 1.0.109", "syn 2.0.68",
] ]
[[package]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "0.2.4" version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70"
dependencies = [
"os_str_bytes",
]
[[package]] [[package]]
name = "cocoa" name = "cocoa"
@ -1090,6 +1129,12 @@ dependencies = [
"objc", "objc",
] ]
[[package]]
name = "colorchoice"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@ -1196,7 +1241,7 @@ dependencies = [
[[package]] [[package]]
name = "creddy" name = "creddy"
version = "0.5.3" version = "0.5.4"
dependencies = [ dependencies = [
"argon2", "argon2",
"auto-launch", "auto-launch",
@ -1204,9 +1249,8 @@ dependencies = [
"aws-sdk-sts", "aws-sdk-sts",
"aws-smithy-types", "aws-smithy-types",
"aws-types", "aws-types",
"base64 0.22.1",
"chacha20poly1305", "chacha20poly1305",
"clap", "creddy_cli",
"dirs 5.0.1", "dirs 5.0.1",
"futures", "futures",
"is-terminal", "is-terminal",
@ -1241,6 +1285,18 @@ dependencies = [
"windows 0.51.1", "windows 0.51.1",
] ]
[[package]]
name = "creddy_cli"
version = "0.5.4"
dependencies = [
"anyhow",
"clap",
"dirs 5.0.1",
"serde",
"serde_json",
"tokio",
]
[[package]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.13" version = "0.5.13"
@ -1399,7 +1455,7 @@ dependencies = [
"ident_case", "ident_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim 0.11.1", "strsim",
"syn 2.0.68", "syn 2.0.68",
] ]
@ -2435,15 +2491,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.3.9" version = "0.3.9"
@ -2766,6 +2813,12 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "0.4.8" version = "0.4.8"
@ -3516,12 +3569,6 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "os_str_bytes"
version = "6.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
[[package]] [[package]]
name = "outref" name = "outref"
version = "0.5.1" version = "0.5.1"
@ -4619,9 +4666,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.118" version = "1.0.120"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d947f6b3163d8857ea16c4fa0dd4840d52f3041039a85decd46867eb1abef2e4" checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5"
dependencies = [ dependencies = [
"itoa 1.0.11", "itoa 1.0.11",
"ryu", "ryu",
@ -5238,12 +5285,6 @@ dependencies = [
"unicode-properties", "unicode-properties",
] ]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@ -5731,21 +5772,6 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "textwrap"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9"
[[package]] [[package]]
name = "thin-slice" name = "thin-slice"
version = "0.1.1" version = "0.1.1"
@ -6217,6 +6243,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.9.1" version = "1.9.1"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "creddy" name = "creddy"
version = "0.5.3" 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,37 +9,40 @@ 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 = "2.0.0-beta", 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 = "2.0.0-beta", features = ["tray-icon"] }
sodiumoxide = "0.2.7" sodiumoxide = "0.2.7"
tokio = { version = ">=1.19", features = ["full"] }
sysinfo = "0.26.8" sysinfo = "0.26.8"
aws-config = "1.5.3" aws-config = "1.5.3"
aws-types = "1.3.2" aws-types = "1.3.2"
aws-sdk-sts = "1.33.0" aws-sdk-sts = "1.33.0"
aws-smithy-types = "1.2.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"] }
@ -55,7 +58,10 @@ ssh-agent-lib = "0.4.0"
ssh-key = { version = "0.6.6", features = ["rsa", "ed25519", "encryption"] } ssh-key = { version = "0.6.6", features = ["rsa", "ed25519", "encryption"] }
signature = "2.2.0" signature = "2.2.0"
tokio-stream = "0.1.15" tokio-stream = "0.1.15"
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "uuid"] } sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "uuid"] }
tokio = { workspace = true }
tokio-util = { version = "0.7.11", features = ["codec"] } tokio-util = { version = "0.7.11", features = ["codec"] }
futures = "0.3.30" futures = "0.3.30"
openssl = "0.10.64" openssl = "0.10.64"
@ -71,8 +77,5 @@ default = ["custom-protocol"]
# DO NOT remove this # DO NOT remove this
custom-protocol = ["tauri/custom-protocol"] custom-protocol = ["tauri/custom-protocol"]
[dev-dependencies]
base64 = "0.22.1"
# [profile.dev.build-override] # [profile.dev.build-override]
# opt-level = 3 # opt-level = 3

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,43 @@
use std::io::{self, Read};
use anyhow::bail;
use crate::proto::{CliResponse, DockerCredential};
use super::{
CliCredential,
CliRequest,
GlobalArgs
};
pub fn docker_store(global_args: GlobalArgs) -> anyhow::Result<()> {
let input: DockerCredential = serde_json::from_reader(io::stdin())?;
let req = CliRequest::SaveCredential {
name: input.username.clone(),
is_default: false, // is_default doesn't really mean anything for Docker credentials
credential: CliCredential::Docker(input),
};
match super::make_request(global_args.server_addr, &req)?? {
CliResponse::Empty => Ok(()),
r => bail!("Unexpected response from server: {r}"),
}
}
pub fn docker_get(global_args: GlobalArgs) -> anyhow::Result<()> {
let mut server_url = String::new();
io::stdin().read_to_string(&mut server_url)?;
let req = CliRequest::GetDockerCredential {
server_url: server_url.trim().to_owned()
};
match super::make_request(global_args.server_addr, &req)?? {
CliResponse::Credential(CliCredential::Docker(d)) => {
println!("{}", serde_json::to_string(&d)?);
},
r => bail!("Unexpected response from server: {r}"),
}
Ok(())
}

View File

@ -0,0 +1,234 @@
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,
};
mod docker;
#[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),
/// Interact with Docker credentials via the docker-credential-helper protocol
#[command(subcommand)]
Docker(DockerCmd),
}
#[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,
}
#[derive(Debug, Subcommand)]
pub enum DockerCmd {
/// Get a stored Docker credential
Get,
/// Store a new Docker credential
Store,
/// Remove a stored Docker credential
Erase,
}
pub fn get(args: GetArgs, global: GlobalArgs) -> anyhow::Result<()> {
let req = CliRequest::GetAwsCredential {
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::GetAwsCredential {
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}"),
}
}
pub fn docker_credential_helper(cmd: DockerCmd, global_args: GlobalArgs) -> anyhow::Result<()> {
match cmd {
DockerCmd::Get => docker::docker_get(global_args),
DockerCmd::Store => docker::docker_store(global_args),
DockerCmd::Erase => todo!(),
}
}
// 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
// (probably this should be modeled differently)
#[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,41 @@
mod cli;
pub use cli::{
Cli,
Action,
exec,
get,
invoke_shortcut,
docker_credential_helper,
};
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,36 @@
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),
Some(Action::Docker(cmd)) => creddy_cli::docker_credential_helper(cmd, 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,113 @@
use std::fmt::{
Display,
Formatter,
Error as FmtError
};
use clap::ValueEnum;
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum CliRequest {
GetAwsCredential {
name: Option<String>,
base: bool,
},
GetDockerCredential {
server_url: String,
},
StoreDockerCredential(DockerCredential),
EraseDockerCredential {
server_url: String,
},
InvokeShortcut{
action: 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::Credential(CliCredential::Docker(_)) => write!(f, "Credential (Docker)"),
CliResponse::Empty => write!(f, "Empty"),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum CliCredential {
AwsBase(AwsBaseCredential),
AwsSession(AwsSessionCredential),
Docker(DockerCredential),
}
#[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, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct DockerCredential {
#[serde(rename = "ServerURL")]
pub server_url: String,
pub username: String,
pub secret: String,
}
#[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 {}

View File

@ -0,0 +1,11 @@
CREATE TABLE docker_credentials (
id BLOB UNIQUE NOT NULL,
-- The Docker credential helper protocol only sends the server_url, so
-- we should guarantee that we will only ever have one matching credential.
-- Also, it's easier to go from unique -> not-unique than vice versa if we
-- decide that's necessary in the future
server_url TEXT UNIQUE NOT NULL,
username TEXT NOT NULL,
secret_enc BLOB NOT NULL,
nonce BLOB NOT NULL
);

View File

@ -1,42 +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 global_matches = cli::parser().get_matches();
let res = match global_matches.subcommand() {
None | Some(("run", _)) => launch_gui(),
Some(("get", m)) => cli::get(m, &global_matches),
Some(("exec", m)) => cli::exec(m, &global_matches),
Some(("shortcut", m)) => cli::invoke_shortcut(m, &global_matches),
_ => 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,227 +0,0 @@
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Command as ChildCommand;
#[cfg(windows)]
use std::time::Duration;
use clap::{
Command,
Arg,
ArgMatches,
ArgAction,
builder::PossibleValuesParser,
value_parser,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::errors::*;
use crate::srv::{
self,
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")
.arg(
Arg::new("server_addr")
.short('a')
.long("server-addr")
.takes_value(true)
.value_parser(value_parser!(PathBuf))
.help("Connect to the main Creddy process at this address")
)
.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")
)
.arg(
Arg::new("name")
.help("If unspecified, use default 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("name")
.short('n')
.long("name")
.takes_value(true)
.help("If unspecified, use default 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, global_args: &ArgMatches) -> Result<(), CliError> {
let name = args.get_one("name").cloned();
let base = *args.get_one("base").unwrap_or(&false);
let addr = global_args.get_one("server_addr").cloned();
let output = match make_request(addr, &Request::GetAwsCredentials { name, base })? {
Response::AwsBase(creds) => serde_json::to_string(&creds).unwrap(),
Response::AwsSession(creds) => serde_json::to_string(&creds).unwrap(),
r => return Err(RequestError::Unexpected(r).into()),
};
println!("{output}");
Ok(())
}
pub fn exec(args: &ArgMatches, global_args: &ArgMatches) -> Result<(), CliError> {
let name = args.get_one("name").cloned();
let base = *args.get_one("base").unwrap_or(&false);
let addr = global_args.get_one("server_addr").cloned();
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 make_request(addr, &Request::GetAwsCredentials { name, base })? {
Response::AwsBase(creds) => {
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
},
Response::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 => return Err(RequestError::Unexpected(r).into()),
}
#[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, global_args: &ArgMatches) -> Result<(), CliError> {
let addr = global_args.get_one("server_addr").cloned();
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(addr, &req) {
Ok(Response::Empty) => Ok(()),
Ok(r) => Err(RequestError::Unexpected(r).into()),
Err(e) => Err(e.into()),
}
}
#[tokio::main]
async fn make_request(addr: Option<PathBuf>, 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(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<Response, ServerError> = serde_json::from_slice(&buf)?;
Ok(res?)
}
#[cfg(windows)]
async fn connect(addr: Option<PathBuf>) -> Result<NamedPipeClient, std::io::Error> {
// apparently attempting to connect can fail if there's already a client connected
loop {
let addr = addr.unwrap_or_else(|| srv::addr("creddy-server"));
match ClientOptions::new().open(&addr) {
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(addr: Option<PathBuf>) -> Result<UnixStream, std::io::Error> {
let path = addr.unwrap_or_else(|| srv::addr("creddy-server"));
UnixStream::connect(&path).await
}

View File

@ -185,10 +185,16 @@ where S: Serializer
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use aws_sdk_sts::primitives::DateTimeFormat;
use creddy_cli::proto::{
AwsBaseCredential as CliBase,
AwsSessionCredential as CliSession,
};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use sqlx::types::uuid::uuid; use sqlx::types::uuid::uuid;
fn creds() -> AwsBaseCredential { fn creds() -> AwsBaseCredential {
AwsBaseCredential::new( AwsBaseCredential::new(
"AKIAIOSFODNN7EXAMPLE".into(), "AKIAIOSFODNN7EXAMPLE".into(),
@ -242,4 +248,98 @@ mod tests {
assert_eq!(&creds().into_credential(), &list[0]); assert_eq!(&creds().into_credential(), &list[0]);
assert_eq!(&creds_2().into_credential(), &list[1]); 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,196 @@
use chacha20poly1305::XNonce;
use serde::{Serialize, Deserialize};
use sqlx::{
FromRow,
Sqlite,
Transaction,
types::Uuid,
};
use super::{Credential, Crypto, PersistentCredential};
use crate::errors::*;
#[derive(Debug, Clone, FromRow)]
pub struct DockerRow {
id: Uuid,
server_url: String,
username: String,
secret_enc: Vec<u8>,
nonce: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct DockerCredential {
#[serde(rename = "ServerURL")]
pub server_url: String,
pub username: String,
pub secret: String,
}
impl PersistentCredential for DockerCredential {
type Row = DockerRow;
fn type_name() -> &'static str { "docker" }
fn into_credential(self) -> Credential { Credential::Docker(self) }
fn row_id(row: &DockerRow) -> Uuid { row.id }
fn from_row(row: DockerRow, crypto: &Crypto) -> Result<Self, LoadCredentialsError> {
let nonce = XNonce::clone_from_slice(&row.nonce);
let secret_bytes = crypto.decrypt(&nonce, &row.secret_enc)?;
let secret = String::from_utf8(secret_bytes)
.map_err(|_| LoadCredentialsError::InvalidData)?;
Ok(DockerCredential {
server_url: row.server_url,
username: row.username,
secret
})
}
async fn save_details(&self, id: &Uuid, crypto: &Crypto, txn: &mut Transaction<'_, Sqlite>) -> Result<(), SaveCredentialsError> {
let (nonce, ciphertext) = crypto.encrypt(self.secret.as_bytes())?;
let nonce_bytes = &nonce.as_slice();
sqlx::query!(
"INSERT OR REPLACE INTO docker_credentials (
id,
server_url,
username,
secret_enc,
nonce
)
VALUES (?, ?, ?, ?, ?)",
id, self.server_url, self.username, ciphertext, nonce_bytes,
).execute(&mut **txn).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::CredentialRecord;
use creddy_cli::proto::DockerCredential as CliDockerCredential;
use sqlx::SqlitePool;
use sqlx::types::uuid::uuid;
fn test_credential() -> DockerCredential {
DockerCredential {
server_url: "https://registry.jfmonty2.com".into(),
username: "joe@jfmonty2.com".into(),
secret: "correct horse battery staple".into(),
}
}
fn test_credential_2() -> DockerCredential {
DockerCredential {
server_url: "https://index.docker.io/v1".into(),
username: "test@example.com".into(),
secret: "a very secure passphrase".into(),
}
}
fn test_record() -> CredentialRecord {
CredentialRecord {
id: uuid!("00000000-0000-0000-0000-000000000000"),
name: "docker_test".into(),
is_default: false,
credential: Credential::Docker(test_credential()),
}
}
fn test_record_2() -> CredentialRecord {
CredentialRecord {
id: uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff"),
name: "docker_test_2".into(),
is_default: false,
credential: Credential::Docker(test_credential_2()),
}
}
#[sqlx::test]
fn test_save(pool: SqlitePool) {
let crypt = Crypto::random();
test_record().save(&crypt, &pool).await
.expect("Failed to save record");
}
#[sqlx::test(fixtures("docker_credentials"))]
fn test_load(pool: SqlitePool) {
let crypt = Crypto::fixed();
let id = uuid!("00000000-0000-0000-0000-000000000000");
let loaded = DockerCredential::load(&id, &crypt, &pool).await
.expect("Failed to load record");
assert_eq!(test_credential(), loaded);
}
#[sqlx::test(fixtures("docker_credentials"))]
async fn test_overwrite(pool: SqlitePool) {
let crypt = Crypto::fixed();
let mut record = test_record_2();
// give it the same id as test_record so that it overwrites
let id = uuid!("00000000-0000-0000-0000-000000000000");
record.id = id;
record.save(&crypt, &pool).await
.expect("Failed to overwrite original record with second record");
let loaded = DockerCredential::load(&id, &crypt, &pool).await
.expect("Failed to load again after overwriting");
assert_eq!(test_credential_2(), loaded);
}
#[sqlx::test(fixtures("docker_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!(test_record(), records[0]);
}
// make sure that CLI credentials and app credentials don't drift apart
#[test]
fn test_cli_to_app() {
let cli_creds = CliDockerCredential {
server_url: "https://registry.jfmonty2.com".into(),
username: "joe@jfmonty2.com".into(),
secret: "correct horse battery staple".into(),
};
let json = serde_json::to_string(&cli_creds).unwrap();
let computed: DockerCredential = serde_json::from_str(&json)
.expect("Failed to deserialize Docker credentials from CLI -> main app");
assert_eq!(test_credential(), computed);
}
#[test]
fn test_app_to_cli() {
let app_creds = test_credential();
let json = serde_json::to_string(&app_creds).unwrap();
let computed: CliDockerCredential = serde_json::from_str(&json)
.expect("Failed to deserialize Docker credentials from main app -> CLI");
let expected = CliDockerCredential {
server_url: "https://registry.jfmonty2.com".into(),
username: "joe@jfmonty2.com".into(),
secret: "correct horse battery staple".into(),
};
assert_eq!(expected, computed);
}
}

View File

@ -0,0 +1,11 @@
INSERT INTO credentials (id, name, credential_type, is_default, created_at)
VALUES (X'00000000000000000000000000000000', 'docker_test', 'docker', 0, 1726756380);
INSERT INTO docker_credentials (id, server_url, username, secret_enc, nonce)
VALUES (
X'00000000000000000000000000000000',
'https://registry.jfmonty2.com',
'joe@jfmonty2.com',
X'C0B36EE54539D4113A8F73E99FB96B2BF4D87E91F7C3B48256C07E83E3E7EC738888B2FDE2B4DB0BE48BEFDE',
X'C5F7F627BBE09A1BB275BE8D2390596C76143881A7766E60'
);

View File

@ -1,3 +1,11 @@
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) INSERT INTO ssh_credentials (id, algorithm, comment, public_key, private_key_enc, nonce)
VALUES VALUES
( (

View File

@ -17,6 +17,9 @@ pub use aws::{AwsBaseCredential, AwsSessionCredential};
mod crypto; mod crypto;
pub use crypto::Crypto; pub use crypto::Crypto;
mod docker;
pub use docker::DockerCredential;
mod record; mod record;
pub use record::CredentialRecord; pub use record::CredentialRecord;
@ -32,6 +35,7 @@ pub use ssh::SshKey;
pub enum Credential { pub enum Credential {
AwsBase(AwsBaseCredential), AwsBase(AwsBaseCredential),
AwsSession(AwsSessionCredential), AwsSession(AwsSessionCredential),
Docker(DockerCredential),
Ssh(SshKey), Ssh(SshKey),
} }
@ -79,6 +83,23 @@ pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized {
Self::from_row(row, crypto) Self::from_row(row, crypto)
} }
async fn load_by<T>(column: &str, value: T, crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError>
where T: Send + for<'q> sqlx::Encode<'q, Sqlite> + sqlx::Type<Sqlite>
{
let query = format!(
"SELECT * FROM {} where {} = ?",
Self::table_name(),
column,
);
let row: Self::Row = sqlx::query_as(&query)
.bind(value)
.fetch_optional(pool)
.await?
.ok_or(LoadCredentialsError::NoCredentials)?;
Self::from_row(row, crypto)
}
async fn load_default(crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> { async fn load_default(crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
let q = format!( let q = format!(
"SELECT details.* "SELECT details.*
@ -99,15 +120,15 @@ pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized {
async fn list(crypto: &Crypto, pool: &SqlitePool) -> Result<Vec<(Uuid, Credential)>, LoadCredentialsError> { async fn list(crypto: &Crypto, pool: &SqlitePool) -> Result<Vec<(Uuid, Credential)>, LoadCredentialsError> {
let q = format!( let q = format!(
"SELECT details.* "SELECT details.*
FROM FROM
{} details {} details
JOIN credentials c JOIN credentials c
ON c.id = details.id ON c.id = details.id
ORDER BY c.created_at", ORDER BY c.created_at",
Self::table_name(), Self::table_name(),
); );
let mut rows = sqlx::query_as::<_, Self::Row>(&q).fetch(pool); let mut rows = sqlx::query_as::<_, Self::Row>(&q).fetch(pool);
let mut creds = Vec::new(); let mut creds = Vec::new();
while let Some(row) = rows.try_next().await? { while let Some(row) = rows.try_next().await? {
let id = Self::row_id(&row); let id = Self::row_id(&row);

View File

@ -20,6 +20,7 @@ use super::{
AwsBaseCredential, AwsBaseCredential,
Credential, Credential,
Crypto, Crypto,
DockerCredential,
PersistentCredential, PersistentCredential,
SshKey, SshKey,
}; };
@ -51,6 +52,7 @@ impl CredentialRecord {
let type_name = match &self.credential { let type_name = match &self.credential {
Credential::AwsBase(_) => AwsBaseCredential::type_name(), Credential::AwsBase(_) => AwsBaseCredential::type_name(),
Credential::Ssh(_) => SshKey::type_name(), Credential::Ssh(_) => SshKey::type_name(),
Credential::Docker(_) => DockerCredential::type_name(),
_ => return Err(SaveCredentialsError::NotPersistent), _ => return Err(SaveCredentialsError::NotPersistent),
}; };
@ -86,6 +88,7 @@ impl CredentialRecord {
match &self.credential { match &self.credential {
Credential::AwsBase(b) => b.save_details(&self.id, crypto, &mut txn).await, Credential::AwsBase(b) => b.save_details(&self.id, crypto, &mut txn).await,
Credential::Ssh(s) => s.save_details(&self.id, crypto, &mut txn).await, Credential::Ssh(s) => s.save_details(&self.id, crypto, &mut txn).await,
Credential::Docker(d) => d.save_details(&self.id, crypto, &mut txn).await,
_ => Err(SaveCredentialsError::NotPersistent), _ => Err(SaveCredentialsError::NotPersistent),
}?; }?;
@ -167,6 +170,11 @@ impl CredentialRecord {
.ok_or(LoadCredentialsError::InvalidData)?; .ok_or(LoadCredentialsError::InvalidData)?;
records.push(Self::from_parts(parent, credential)); records.push(Self::from_parts(parent, credential));
} }
for (id, credential) in DockerCredential::list(crypto, pool).await? {
let parent = parent_map.remove(&id)
.ok_or(LoadCredentialsError::InvalidData)?;
records.push(Self::from_parts(parent, credential));
}
Ok(records) Ok(records)
} }

View File

@ -299,6 +299,8 @@ fn deserialize_algorithm<'de, D>(deserializer: D) -> Result<Algorithm, D::Error>
mod tests { mod tests {
use std::fs::{self, File}; use std::fs::{self, File};
use sqlx::types::uuid::uuid; use sqlx::types::uuid::uuid;
use crate::credentials::CredentialRecord;
use super::*; use super::*;
fn path(name: &str) -> String { fn path(name: &str) -> String {
@ -434,11 +436,14 @@ mod tests {
#[sqlx::test] #[sqlx::test]
async fn test_save_db(pool: SqlitePool) { async fn test_save_db(pool: SqlitePool) {
let crypto = Crypto::random(); let crypto = Crypto::random();
let k = rsa_plain(); let record = CredentialRecord {
let mut txn = pool.begin().await.unwrap(); id: random_uuid(),
k.save_details(&random_uuid(), &crypto, &mut txn).await name: "save_test".into(),
.expect("Failed to save SSH key to database"); is_default: false,
txn.commit().await.expect("Failed to finalize transaction"); credential: Credential::Ssh(rsa_plain()),
};
record.save(&crypto, &pool).await
.expect("Failed to save SSH key CredentialRecord to database");
} }
@ -454,13 +459,18 @@ mod tests {
#[sqlx::test] #[sqlx::test]
async fn test_save_load_db(pool: SqlitePool) { async fn test_save_load_db(pool: SqlitePool) {
let crypto = Crypto::random(); let crypto = Crypto::random();
let id = uuid!("7bc994dd-113a-4841-bcf7-b47c2fffdd25");
let known = ed25519_plain();
let mut txn = pool.begin().await.unwrap();
known.save_details(&id, &crypto, &mut txn).await.unwrap();
txn.commit().await.unwrap();
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 loaded = SshKey::load(&id, &crypto, &pool).await.unwrap();
let known = ed25519_plain();
assert_eq!(known.algorithm, loaded.algorithm); assert_eq!(known.algorithm, loaded.algorithm);
assert_eq!(known.comment, loaded.comment); assert_eq!(known.comment, loaded.comment);

View File

@ -36,7 +36,7 @@ pub trait ShowError<T, E>
fn error_print_prefix(self, prefix: &str); fn error_print_prefix(self, prefix: &str);
} }
impl<T, E> ShowError<T, E> for Result<T, E> impl<T, E> ShowError<T, E> for Result<T, E>
where E: std::fmt::Display where E: std::fmt::Display
{ {
fn error_popup(self, title: &str) { fn error_popup(self, title: &str) {
@ -91,7 +91,7 @@ impl<E: Error> Serialize for SerializeUpstream<E> {
} }
} }
fn serialize_upstream_err<E, M>(err: &E, map: &mut M) -> Result<(), M::Error> 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,
@ -173,7 +173,7 @@ pub enum HandlerError {
StreamIOError(#[from] std::io::Error), StreamIOError(#[from] std::io::Error),
#[error("Received invalid UTF-8 in request")] #[error("Received invalid UTF-8 in request")]
InvalidUtf8(#[from] FromUtf8Error), InvalidUtf8(#[from] FromUtf8Error),
#[error("HTTP request malformed")] #[error("Request malformed: {0}")]
BadRequest(#[from] serde_json::Error), BadRequest(#[from] serde_json::Error),
#[error("HTTP request too large")] #[error("HTTP request too large")]
RequestTooLarge, RequestTooLarge,
@ -183,6 +183,8 @@ pub enum HandlerError {
Internal(#[from] RecvError), Internal(#[from] RecvError),
#[error("Error accessing credentials: {0}")] #[error("Error accessing credentials: {0}")]
NoCredentials(#[from] GetCredentialsError), NoCredentials(#[from] GetCredentialsError),
#[error("Error saving credentials: {0}")]
SaveCredentials(#[from] SaveCredentialsError),
#[error("Error getting client details: {0}")] #[error("Error getting client details: {0}")]
ClientInfo(#[from] ClientInfoError), ClientInfo(#[from] ClientInfoError),
#[error("Error from Tauri: {0}")] #[error("Error from Tauri: {0}")]
@ -370,7 +372,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::srv::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}")]

View File

@ -16,7 +16,6 @@ use crate::terminal;
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AwsRequestNotification { pub struct AwsRequestNotification {
pub id: u64,
pub client: Client, pub client: Client,
pub name: Option<String>, pub name: Option<String>,
pub base: bool, pub base: bool,
@ -25,27 +24,46 @@ pub struct AwsRequestNotification {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SshRequestNotification { pub struct SshRequestNotification {
pub id: u64,
pub client: Client, pub client: Client,
pub key_name: String, pub key_name: String,
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type")] pub struct DockerRequestNotification {
pub enum RequestNotification { pub client: Client,
Aws(AwsRequestNotification), pub server_url: String,
Ssh(SshRequestNotification),
} }
impl RequestNotification {
pub fn new_aws(id: u64, client: Client, name: Option<String>, base: bool) -> Self { #[derive(Clone, Debug, Serialize, Deserialize)]
Self::Aws(AwsRequestNotification {id, client, name, base}) #[serde(tag = "type")]
pub enum RequestNotificationDetail {
Aws(AwsRequestNotification),
Ssh(SshRequestNotification),
Docker(DockerRequestNotification),
}
impl RequestNotificationDetail {
pub fn new_aws(client: Client, name: Option<String>, base: bool) -> Self {
Self::Aws(AwsRequestNotification {client, name, base})
} }
pub fn new_ssh(id: u64, client: Client, key_name: String) -> Self { pub fn new_ssh(client: Client, key_name: String) -> Self {
Self::Ssh(SshRequestNotification {id, client, key_name}) Self::Ssh(SshRequestNotification {client, key_name})
} }
pub fn new_docker(client: Client, server_url: String) -> Self {
Self::Docker(DockerRequestNotification {client, server_url})
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RequestNotification {
pub id: u64,
#[serde(flatten)]
pub detail: RequestNotificationDetail,
} }

View File

@ -1,5 +1,4 @@
pub mod app; pub mod app;
pub mod cli;
mod config; mod config;
mod credentials; mod credentials;
pub mod errors; pub mod errors;

View File

@ -3,24 +3,25 @@
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 global_matches = cli::parser().get_matches(); let cli = Cli::parse();
let res = match global_matches.subcommand() { let res = match cli.action {
None | Some(("run", _)) => { None | Some(Action::Run) => {
app::run().error_popup("Creddy encountered an error"); app::run().error_popup("Creddy encountered an error");
Ok(()) Ok(())
}, },
Some(("get", m)) => cli::get(m, &global_matches), Some(Action::Get(args)) => creddy_cli::get(args, cli.global_args),
Some(("exec", m)) => cli::exec(m, &global_matches), Some(Action::Exec(args)) => creddy_cli::exec(args, cli.global_args),
Some(("shortcut", m)) => cli::invoke_shortcut(m, &global_matches), Some(Action::Shortcut(args)) => creddy_cli::invoke_shortcut(args, cli.global_args),
_ => unreachable!(), Some(Action::Docker(cmd)) => creddy_cli::docker_credential_helper(cmd, cli.global_args),
}; };
if let Err(e) = res { if let Err(e) = res {

View File

@ -11,7 +11,7 @@ use tokio_util::codec::Framed;
use crate::clientinfo; use crate::clientinfo;
use crate::errors::*; use crate::errors::*;
use crate::ipc::{Approval, RequestNotification}; use crate::ipc::{Approval, RequestNotification, RequestNotificationDetail};
use crate::state::AppState; use crate::state::AppState;
use super::{CloseWaiter, Stream}; use super::{CloseWaiter, Stream};
@ -40,7 +40,7 @@ async fn handle(
// corrupt the framing. Clients don't seem to behave that way though? // corrupt the framing. Clients don't seem to behave that way though?
let waiter = CloseWaiter { stream: adapter.get_mut() }; let waiter = CloseWaiter { stream: adapter.get_mut() };
let resp = sign_request(req, app_handle.clone(), client_pid, waiter).await?; 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 // have to do this before we send since we can't inspect the message after
let is_failure = matches!(resp, Message::Failure); let is_failure = matches!(resp, Message::Failure);
adapter.send(resp).await?; adapter.send(resp).await?;
@ -69,47 +69,21 @@ async fn sign_request(
req: SignRequest, req: SignRequest,
app_handle: AppHandle, app_handle: AppHandle,
client_pid: u32, client_pid: u32,
mut waiter: CloseWaiter<'_>, waiter: CloseWaiter<'_>,
) -> Result<Message, HandlerError> { ) -> Result<Message, HandlerError> {
let state = app_handle.state::<AppState>(); 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 client = clientinfo::get_client(client_pid, false)?;
let lease = state.acquire_visibility_lease(rehide_ms).await let key_name = state.ssh_name_from_pubkey(&req.pubkey_blob).await?;
.map_err(|_e| HandlerError::NoMainWindow)?; let detail = RequestNotificationDetail::new_ssh(client, key_name.clone());
let (chan_send, chan_recv) = oneshot::channel(); let response = super::send_credentials_request(detail, app_handle.clone(), waiter).await?;
let request_id = state.register_request(chan_send).await; match response.approval {
Approval::Approved => {
let proceed = async { let key = state.sshkey_by_name(&key_name).await?;
let key_name = state.ssh_name_from_pubkey(&req.pubkey_blob).await?; let sig = key.sign_request(&req)?;
let notification = RequestNotification::new_ssh(request_id, client, key_name.clone()); Ok(Message::SignResponse(sig))
app_handle.emit("credential-request", &notification)?; },
Approval::Denied => Err(HandlerError::Abandoned),
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

@ -1,16 +1,23 @@
use sqlx::types::uuid::Uuid;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot; use tokio::sync::oneshot;
use crate::clientinfo::{self, Client}; use crate::clientinfo::{self, Client};
use crate::credentials::{
Credential,
CredentialRecord,
Crypto
};
use crate::errors::*; use crate::errors::*;
use crate::ipc::{Approval, RequestNotification}; use crate::ipc::{Approval, AwsRequestNotification, RequestNotificationDetail, RequestResponse};
use crate::shortcuts::{self, ShortcutAction}; use crate::shortcuts::{self, ShortcutAction};
use crate::state::AppState; use crate::state::AppState;
use super::{ use super::{
CloseWaiter, CloseWaiter,
Request, CliCredential,
Response, CliRequest,
CliResponse,
Stream, Stream,
}; };
@ -43,13 +50,18 @@ async fn handle(
let waiter = CloseWaiter { stream: &mut stream }; let waiter = CloseWaiter { stream: &mut stream };
let req: Request = serde_json::from_slice(&buf)?; let req: CliRequest = serde_json::from_slice(&buf)?;
let res = match req { let res = match req {
Request::GetAwsCredentials { name, base } => get_aws_credentials( CliRequest::GetAwsCredential{ name, base } => get_aws_credentials(
name, base, client, app_handle, waiter name, base, client, app_handle, waiter
).await, ).await,
Request::InvokeShortcut(action) => invoke_shortcut(action).await, CliRequest::GetDockerCredential{ server_url } => get_docker_credentials (
Request::GetSshSignature(_) => return Err(HandlerError::Denied), server_url, client, app_handle, waiter
).await,
CliRequest::SaveCredential{ name, is_default, credential } => save_credential(
name, is_default, credential, app_handle
).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 // doesn't make sense to send the error to the client if the client has already left
@ -63,9 +75,9 @@ async fn handle(
} }
async fn invoke_shortcut(action: ShortcutAction) -> Result<Response, HandlerError> { async fn invoke_shortcut(action: ShortcutAction) -> Result<CliResponse, HandlerError> {
shortcuts::exec_shortcut(action); shortcuts::exec_shortcut(action);
Ok(Response::Empty) Ok(CliResponse::Empty)
} }
@ -74,59 +86,64 @@ async fn get_aws_credentials(
base: bool, base: bool,
client: Client, client: Client,
app_handle: AppHandle, app_handle: AppHandle,
mut waiter: CloseWaiter<'_>, waiter: CloseWaiter<'_>,
) -> Result<Response, HandlerError> { ) -> Result<CliResponse, HandlerError> {
let state = app_handle.state::<AppState>(); let detail = RequestNotificationDetail::new_aws(client, name.clone(), base);
let rehide_ms = { let response = super::send_credentials_request(detail, app_handle.clone(), waiter).await?;
let config = state.config.read().await; match response.approval {
config.rehide_ms Approval::Approved => {
}; let state = app_handle.state::<AppState>();
let lease = state.acquire_visibility_lease(rehide_ms).await if response.base {
.map_err(|_e| HandlerError::NoMainWindow)?; // automate this conversion eventually? let creds = state.get_aws_base(name).await?;
Ok(CliResponse::Credential(CliCredential::AwsBase(creds)))
let (chan_send, chan_recv) = oneshot::channel(); }
let request_id = state.register_request(chan_send).await; else {
let creds = state.get_aws_session(name).await?.clone();
// if an error occurs in any of the following, we want to abort the operation Ok(CliResponse::Credential(CliCredential::AwsSession(creds)))
// 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(Response::AwsBase(creds))
}
else {
let creds = state.get_aws_session(name).await?;
Ok(Response::AwsSession(creds.clone()))
}
},
Approval::Denied => Err(HandlerError::Denied),
}
};
let result = match proceed.await {
Ok(r) => Ok(r),
Err(e) => {
state.unregister_request(request_id).await;
Err(e)
}, },
}; Approval::Denied => Err(HandlerError::Denied),
}
}
lease.release(); async fn get_docker_credentials(
result server_url: String,
} client: Client,
app_handle: AppHandle,
waiter: CloseWaiter<'_>,
) -> Result<CliResponse, HandlerError> {
let detail = RequestNotificationDetail::new_docker(client, server_url.clone());
let response = super::send_credentials_request(detail, app_handle.clone(), waiter).await?;
match response.approval {
Approval::Approved => {
let state = app_handle.state::<AppState>();
let creds = state.get_docker_credential(&server_url).await?;
Ok(CliResponse::Credential(CliCredential::Docker(creds)))
},
Approval::Denied => {
Err(HandlerError::Denied)
},
}
}
pub async fn save_credential(
name: String,
is_default: bool,
credential: Credential,
app_handle: AppHandle,
) -> Result<CliResponse, HandlerError> {
let state = app_handle.state::<AppState>();
// eventually ask the frontend to unlock here
// a bit weird but convenient
let random_bytes = Crypto::salt();
let id = Uuid::from_slice(&random_bytes[..16]).unwrap();
let record = CredentialRecord {
id, name, is_default, credential
};
state.save_credential(record).await?;
Ok(CliResponse::Empty)
}

View File

@ -3,37 +3,62 @@ use std::future::Future;
use tauri::{ use tauri::{
AppHandle, AppHandle,
async_runtime as rt, async_runtime as rt,
Manager,
}; };
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio::sync::oneshot;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use ssh_agent_lib::proto::message::SignRequest;
use crate::credentials::{AwsBaseCredential, AwsSessionCredential}; use crate::clientinfo::Client;
use crate::credentials::{
AwsBaseCredential,
AwsSessionCredential,
Credential,
DockerCredential,
};
use crate::errors::*; use crate::errors::*;
use crate::ipc::{RequestNotification, RequestNotificationDetail, RequestResponse};
use crate::shortcuts::ShortcutAction; use crate::shortcuts::ShortcutAction;
use crate::state::AppState;
pub mod creddy_server; pub mod creddy_server;
pub mod agent; pub mod agent;
use platform::Stream; use platform::Stream;
pub use platform::addr;
// 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)] #[derive(Debug, Serialize, Deserialize)]
pub enum Request { pub enum CliRequest {
GetAwsCredentials { GetAwsCredential {
name: Option<String>, name: Option<String>,
base: bool, base: bool,
}, },
GetSshSignature(SignRequest), GetDockerCredential {
server_url: String,
},
SaveCredential {
name: String,
is_default: bool,
credential: Credential,
},
InvokeShortcut(ShortcutAction), InvokeShortcut(ShortcutAction),
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub enum Response { pub enum CliResponse {
Credential(CliCredential),
Empty,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum CliCredential {
AwsBase(AwsBaseCredential), AwsBase(AwsBaseCredential),
AwsSession(AwsSessionCredential), AwsSession(AwsSessionCredential),
Empty, Docker(DockerCredential),
} }
@ -81,6 +106,48 @@ fn serve<H, F>(sock_name: &str, app_handle: AppHandle, handler: H) -> std::io::R
} }
async fn send_credentials_request(
detail: RequestNotificationDetail,
app_handle: AppHandle,
mut waiter: CloseWaiter<'_>
) -> Result<RequestResponse, 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)?;
let (chan_send, chan_recv) = oneshot::channel();
let request_id = state.register_request(chan_send).await;
let notification = RequestNotification { id: request_id, detail };
// the following could fail in various ways, but we want to make sure
// the request gets unregistered on any failure, so we wrap this all
// up in an async block so that we only have to handle the error case once
let proceed = async {
app_handle.emit("credential-request", &notification)?;
tokio::select! {
r = chan_recv => Ok(r?),
_ = waiter.wait_for_close() => {
app_handle.emit("request-cancelled", request_id)?;
Err(HandlerError::Abandoned)
},
}
};
let res = proceed.await;
if let Err(_) = &res {
state.unregister_request(request_id).await;
}
lease.release();
res
}
#[cfg(unix)] #[cfg(unix)]
mod platform { mod platform {
use std::io::ErrorKind; use std::io::ErrorKind;
@ -92,7 +159,7 @@ mod platform {
pub type Stream = UnixStream; pub type Stream = UnixStream;
pub fn bind(sock_name: &str) -> std::io::Result<(UnixListener, PathBuf)> { pub fn bind(sock_name: &str) -> std::io::Result<(UnixListener, PathBuf)> {
let path = addr(sock_name); let path = creddy_cli::server_addr(sock_name);
match std::fs::remove_file(&path) { match std::fs::remove_file(&path) {
Ok(_) => (), Ok(_) => (),
Err(e) if e.kind() == ErrorKind::NotFound => (), Err(e) if e.kind() == ErrorKind::NotFound => (),
@ -112,14 +179,6 @@ mod platform {
Ok((stream, pid)) Ok((stream, pid))
} }
pub fn addr(sock_name: &str) -> PathBuf {
let mut path = dirs::runtime_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"));
path.push(format!("{sock_name}.sock"));
path
}
} }
@ -140,7 +199,7 @@ mod platform {
pub type Stream = NamedPipeServer; pub type Stream = NamedPipeServer;
pub fn bind(sock_name: &str) -> std::io::Result<(String, NamedPipeServer)> { pub fn bind(sock_name: &str) -> std::io::Result<(String, NamedPipeServer)> {
let addr = addr(sock_name); let addr = creddy_cli::server_addr(sock_name);
let listener = ServerOptions::new() let listener = ServerOptions::new()
.first_pipe_instance(true) .first_pipe_instance(true)
.create(&addr)?; .create(&addr)?;
@ -163,8 +222,4 @@ mod platform {
unsafe { GetNamedPipeClientProcessId(handle, &mut pid as *mut u32)? }; unsafe { GetNamedPipeClientProcessId(handle, &mut pid as *mut u32)? };
Ok((stream, pid)) Ok((stream, pid))
} }
pub fn addr(sock_name: &str) -> String {
format!(r"\\.\pipe\{sock_name}")
}
} }

View File

@ -19,6 +19,7 @@ use crate::app;
use crate::credentials::{ use crate::credentials::{
AppSession, AppSession,
AwsSessionCredential, AwsSessionCredential,
DockerCredential,
SshKey, SshKey,
}; };
use crate::{config, config::AppConfig}; use crate::{config, config::AppConfig};
@ -193,7 +194,7 @@ impl AppState {
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> { pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
let mut live_config = self.config.write().await; let mut live_config = self.config.write().await;
// update autostart if necessary // update autostart if necessary
if new_config.start_on_login != live_config.start_on_login { if new_config.start_on_login != live_config.start_on_login {
config::set_auto_launch(new_config.start_on_login)?; config::set_auto_launch(new_config.start_on_login)?;
@ -322,6 +323,13 @@ impl AppState {
Ok(k) Ok(k)
} }
pub async fn get_docker_credential(&self, server_url: &str) -> Result<DockerCredential, GetCredentialsError> {
let app_session = self.app_session.read().await;
let crypto = app_session.try_get_crypto()?;
let d = DockerCredential::load_by("server_url", server_url.to_owned(), crypto, &self.pool).await?;
Ok(d)
}
pub async fn signal_activity(&self) { pub async fn signal_activity(&self) {
let mut last_activity = self.last_activity.write().await; let mut last_activity = self.last_activity.write().await;
*last_activity = OffsetDateTime::now_utc(); *last_activity = OffsetDateTime::now_utc();

View File

@ -50,7 +50,7 @@
} }
}, },
"productName": "creddy", "productName": "creddy",
"version": "0.5.3", "version": "0.5.4",
"identifier": "creddy", "identifier": "creddy",
"plugins": {}, "plugins": {},
"app": { "app": {

View File

@ -19,7 +19,7 @@
let alert; let alert;
let passphrase = ''; let passphrase = '';
let saving = false; let saving = false;
async function unlock() { async function unlock() {
saving = true; saving = true;
@ -40,6 +40,8 @@
</script> </script>
<svelte:window on:focus={input.focus} />
<div class="fixed top-0 w-full p-2 text-center"> <div class="fixed top-0 w-full p-2 text-center">
<h1 class="text-3xl font-bold">Creddy is locked</h1> <h1 class="text-3xl font-bold">Creddy is locked</h1>
</div> </div>

View File

@ -34,7 +34,7 @@
<div> <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> <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> <span>
WARNING: This application is requesting your base AWS credentials. WARNING: This application is requesting your base AWS credentials.
These credentials are less secure than session credentials, since they don't expire automatically. These credentials are less secure than session credentials, since they don't expire automatically.
</span> </span>
</div> </div>
@ -51,6 +51,8 @@
{/if} {/if}
{:else if $appState.currentRequest.type === 'Ssh'} {:else if $appState.currentRequest.type === 'Ssh'}
{appName ? `"${appName}"` : 'An application'} would like to use your SSH key "{$appState.currentRequest.key_name}". {appName ? `"${appName}"` : 'An application'} would like to use your SSH key "{$appState.currentRequest.key_name}".
{:else if $appState.currentRequest.type === 'Docker'}
{appName ? `"${appName}"` : 'An application'} would like to use your Docker credentials for <code>{$appState.currentRequest.server_url}</code>.
{/if} {/if}
</h2> </h2>