1 Commits

17 changed files with 69 additions and 178 deletions

View File

@ -1,15 +1,13 @@
## Definitely ## Definitely
* ~~Switch to "process" provider for AWS credentials (much less hacky)~~ * Switch to "process" provider for AWS credentials (much less hacky)
* ~~Frontend needs to react when request is cancelled from backend~~
* Session timeout (plain duration, or activity-based?) * Session timeout (plain duration, or activity-based?)
* ~~Fix rehide behavior when new request comes in while old one is still being resolved~~ * ~Fix rehide behavior when new request comes in while old one is still being resolved~
* 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

View File

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

2
src-tauri/Cargo.lock generated
View File

@ -1035,7 +1035,7 @@ dependencies = [
[[package]] [[package]]
name = "creddy" name = "creddy"
version = "0.4.5" version = "0.4.1"
dependencies = [ dependencies = [
"argon2", "argon2",
"auto-launch", "auto-launch",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "creddy" name = "creddy"
version = "0.4.5" version = "0.4.1"
description = "A friendly AWS credentials manager" description = "A friendly AWS credentials manager"
authors = ["Joseph Montanaro"] authors = ["Joseph Montanaro"]
license = "" license = ""

View File

@ -14,6 +14,7 @@ pub struct Client {
pub fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> { pub fn get_process_parent_info(pid: u32) -> 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);

View File

@ -126,10 +126,10 @@ impl LockedCredentials {
let secret_access_key = String::from_utf8(decrypted) let secret_access_key = String::from_utf8(decrypted)
.map_err(|_| UnlockError::InvalidUtf8)?; .map_err(|_| UnlockError::InvalidUtf8)?;
let creds = BaseCredentials::new( let creds = BaseCredentials {
self.access_key_id.clone(), access_key_id: self.access_key_id.clone(),
secret_access_key, secret_access_key,
); };
Ok(creds) Ok(creds)
} }
} }
@ -138,16 +138,11 @@ impl LockedCredentials {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
pub struct BaseCredentials { pub struct BaseCredentials {
pub version: usize,
pub access_key_id: String, pub access_key_id: String,
pub secret_access_key: String, pub secret_access_key: String,
} }
impl BaseCredentials { impl BaseCredentials {
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
Self {version: 1, access_key_id, secret_access_key}
}
pub fn encrypt(&self, passphrase: &str) -> Result<LockedCredentials, CryptoError> { pub fn encrypt(&self, passphrase: &str) -> Result<LockedCredentials, CryptoError> {
let salt = Crypto::salt(); let salt = Crypto::salt();
let crypto = Crypto::new(passphrase, &salt)?; let crypto = Crypto::new(passphrase, &salt)?;

View File

@ -83,33 +83,15 @@ where
} }
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> 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(())
} }
@ -171,7 +153,7 @@ pub enum SendResponseError {
} }
// errors encountered while handling a client request // errors encountered while handling an HTTP 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}")]
@ -182,8 +164,6 @@ 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(#[from] RecvError), Internal(#[from] RecvError),
#[error("Error accessing credentials: {0}")] #[error("Error accessing credentials: {0}")]
@ -365,6 +345,7 @@ 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);

View File

@ -43,24 +43,6 @@ pub enum Response {
} }
struct CloseWaiter<'s> {
stream: &'s mut Stream,
}
impl<'s> CloseWaiter<'s> {
async fn wait_for_close(&mut self) -> std::io::Result<()> {
let mut buf = [0u8; 8];
loop {
match self.stream.read(&mut buf).await {
Ok(0) => break Ok(()),
Ok(_) => (),
Err(e) => break Err(e),
}
}
}
}
async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> Result<(), HandlerError> async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> Result<(), HandlerError>
{ {
// read from stream until delimiter is reached // read from stream until delimiter is reached
@ -77,21 +59,13 @@ async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> R
} }
let client = clientinfo::get_process_parent_info(client_pid)?; let client = clientinfo::get_process_parent_info(client_pid)?;
let waiter = CloseWaiter { stream: &mut stream };
let req: Request = serde_json::from_slice(&buf)?; let req: Request = serde_json::from_slice(&buf)?;
let res = match req { let res = match req {
Request::GetAwsCredentials{ base } => get_aws_credentials( Request::GetAwsCredentials{ base } => get_aws_credentials(base, client, app_handle).await,
base, client, app_handle, waiter
).await,
Request::InvokeShortcut(action) => invoke_shortcut(action).await, Request::InvokeShortcut(action) => invoke_shortcut(action).await,
}; };
// doesn't make sense to send the error to the client if the client has already left
if let Err(HandlerError::Abandoned) = res {
return Err(HandlerError::Abandoned);
}
let res = serde_json::to_vec(&res).unwrap(); let res = serde_json::to_vec(&res).unwrap();
stream.write_all(&res).await?; stream.write_all(&res).await?;
Ok(()) Ok(())
@ -104,12 +78,7 @@ async fn invoke_shortcut(action: ShortcutAction) -> Result<Response, HandlerErro
} }
async fn get_aws_credentials( async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle) -> Result<Response, HandlerError> {
base: bool,
client: Client,
app_handle: AppHandle,
mut waiter: CloseWaiter<'_>,
) -> Result<Response, HandlerError> {
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
let rehide_ms = { let rehide_ms = {
let config = state.config.read().await; let config = state.config.read().await;
@ -128,14 +97,7 @@ async fn get_aws_credentials(
let notification = AwsRequestNotification {id: request_id, client, base}; let notification = AwsRequestNotification {id: request_id, client, base};
app_handle.emit_all("credentials-request", &notification)?; app_handle.emit_all("credentials-request", &notification)?;
let response = tokio::select! { let response = chan_recv.await?;
r = chan_recv => r?,
_ = waiter.wait_for_close() => {
app_handle.emit_all("request-cancelled", request_id)?;
return Err(HandlerError::Abandoned);
},
};
match response.approval { match response.approval {
Approval::Approved => { Approval::Approved => {
if response.base { if response.base {

View File

@ -8,7 +8,7 @@
}, },
"package": { "package": {
"productName": "creddy", "productName": "creddy",
"version": "0.4.5" "version": "0.4.1"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {

View File

@ -3,7 +3,7 @@ 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/tauri';
import { appState, acceptRequest, cleanupRequest } from './lib/state.js'; import { appState, acceptRequest } from './lib/state.js';
import { views, currentView, navigate } from './lib/routing.js'; import { views, currentView, navigate } from './lib/routing.js';
@ -16,16 +16,6 @@ listen('credentials-request', (tauriEvent) => {
$appState.pendingRequests.put(tauriEvent.payload); $appState.pendingRequests.put(tauriEvent.payload);
}); });
listen('request-cancelled', (tauriEvent) => {
const id = tauriEvent.payload;
if (id === $appState.currentRequest?.id) {
cleanupRequest()
}
else {
const found = $appState.pendingRequests.find_remove(r => r.id === id);
}
});
listen('launch-terminal-request', async (tauriEvent) => { listen('launch-terminal-request', async (tauriEvent) => {
if ($appState.currentRequest === null) { if ($appState.currentRequest === null) {
let status = await invoke('get_session_status'); let status = await invoke('get_session_status');

View File

@ -30,15 +30,5 @@ 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

@ -23,7 +23,7 @@ export async function acceptRequest() {
} }
export function cleanupRequest() { export function completeRequest() {
appState.update($appState => { appState.update($appState => {
$appState.currentRequest = null; $appState.currentRequest = null;
return $appState; return $appState;

View File

@ -1,15 +1,13 @@
<script> <script>
export let keys; export let keys;
let classes;
export {classes as class};
</script> </script>
<span class="inline-flex gap-x-[0.2em] items-center {classes}"> <div class="flex gap-x-[0.2em] items-center">
{#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}
</span> </div>

View File

@ -21,15 +21,15 @@
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 ( if (!hotkey) return;
hotkey === event.key if (ctrl && !event.ctrlKey) return;
&& ctrl === event.ctrlKey if (alt && !event.altKey) return;
&& alt === event.altKey if (shift && !event.shiftKey) return;
&& shift === event.shiftKey
) { if (event.key === hotkey) {
click(); click();
} }
} }

View File

@ -3,7 +3,7 @@
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { navigate } from '../lib/routing.js'; import { navigate } from '../lib/routing.js';
import { appState, cleanupRequest } from '../lib/state.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 Link from '../ui/Link.svelte';
import KeyCombo from '../ui/KeyCombo.svelte'; import KeyCombo from '../ui/KeyCombo.svelte';
@ -11,13 +11,11 @@
// Send response to backend, display error if applicable // Send response to backend, display error if applicable
let error, alert; let error, alert;
let base = $appState.currentRequest.base;
async function respond() { async function respond() {
const response = { let {id, approval} = $appState.currentRequest;
id: $appState.currentRequest.id,
...$appState.currentRequest.response,
};
try { try {
await invoke('respond', {response}); await invoke('respond', {response: {id, approval, base}});
navigate('ShowResponse'); navigate('ShowResponse');
} }
catch (e) { catch (e) {
@ -29,8 +27,8 @@
} }
// Approval has one of several outcomes depending on current credential state // Approval has one of several outcomes depending on current credential state
async function approve(base) { async function approve() {
$appState.currentRequest.response = {approval: 'Approved', base}; $appState.currentRequest.approval = 'Approved';
let status = await invoke('get_session_status'); let status = await invoke('get_session_status');
if (status === 'unlocked') { if (status === 'unlocked') {
await respond(); await respond();
@ -43,16 +41,9 @@
} }
} }
function approve_base() {
approve(true);
}
function approve_session() {
approve(false);
}
// Denial has only one // Denial has only one
async function deny() { async function deny() {
$appState.currentRequest.response = {approval: 'Denied', base: false}; $appState.currentRequest.approval = 'Denied';
await respond(); await respond();
} }
@ -68,32 +59,32 @@
// if the request has already been approved/denied, send response immediately // if the request has already been approved/denied, send response immediately
onMount(async () => { onMount(async () => {
if ($appState.currentRequest.response) { if ($appState.currentRequest.approval) {
await respond(); await respond();
} }
}); })
</script> </script>
<!-- Don't render at all if we're just going to immediately proceed to the next screen --> <!-- Don't render at all if we're just going to immediately proceed to the next screen -->
{#if error || !$appState.currentRequest?.response} {#if error || !$appState.currentRequest.approval}
<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} {#if error}
<ErrorAlert bind:this={alert}> <ErrorAlert bind:this={alert}>
{error.msg} {error}
<svelte:fragment slot="buttons"> <svelte:fragment slot="buttons">
<button class="btn btn-sm btn-alert-error" on:click={cleanupRequest}>Cancel</button> <button class="btn btn-sm btn-alert-error" on:click={completeRequest}>Cancel</button>
<button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button> <button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button>
</svelte:fragment> </svelte:fragment>
</ErrorAlert> </ErrorAlert>
{/if} {/if}
{#if $appState.currentRequest?.base} {#if $appState.currentRequest.base}
<div class="alert alert-warning shadow-lg"> <div class="alert alert-warning shadow-lg">
<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 long-lived 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>
@ -111,42 +102,27 @@
</div> </div>
</div> </div>
<div class="w-full grid grid-cols-[1fr_auto] items-center gap-y-6"> <div class="w-full flex justify-between">
<!-- Don't display the option to approve with session credentials if base was specifically requested --> <Link target={deny} hotkey="Escape">
{#if !$appState.currentRequest?.base} <button class="btn btn-error justify-self-start">
<h3 class="font-semibold">
Approve with session credentials
</h3>
<Link target={() => approve(false)} hotkey="Enter" shift={true}>
<button class="w-full btn btn-success">
<KeyCombo keys={['Shift', 'Enter']} />
</button>
</Link>
{/if}
<h3 class="font-semibold">
<span class="mr-2">
{#if $appState.currentRequest?.base}
Approve
{:else}
Approve with base credentials
{/if}
</span>
</h3>
<Link target={() => approve(true)} hotkey="Enter" shift={true} ctrl={true}>
<button class="w-full btn btn-warning">
<KeyCombo keys={['Ctrl', 'Shift', 'Enter']} />
</button>
</Link>
<h3 class="font-semibold">
<span class="mr-2">Deny</span> <span class="mr-2">Deny</span>
</h3> <KeyCombo keys={['Esc']} />
<Link target={deny} hotkey="Escape"> </button>
<button class="w-full btn btn-error"> </Link>
<KeyCombo keys={['Esc']} />
</button> <Link target={approve} hotkey="Enter" shift="{true}">
</Link> <button class="btn btn-success justify-self-end">
<span class="mr-2">Approve</span>
<KeyCombo keys={['Shift', 'Enter']} />
</button>
</Link>
</div>
<div class="w-full">
<label class="label cursor-pointer justify-end gap-x-2">
<span class="label-text">Send long-lived credentials</span>
<input type="checkbox" class="checkbox checkbox-success" bind:checked={base}>
</label>
</div> </div>
</div> </div>
{/if} {/if}

View File

@ -112,7 +112,7 @@
<div> <div>
<!-- <button class="btn btn-sm btn-ghost">Cancel</button> --> <!-- <button class="btn btn-sm btn-ghost">Cancel</button> -->
<button class="btn btn-sm btn-primary" on:click={save}>Save</button> <buton class="btn btn-sm btn-primary" on:click={save}>Save</buton>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { draw, fade } from 'svelte/transition'; import { draw, fade } from 'svelte/transition';
import { appState, cleanupRequest } from '../lib/state.js'; import { appState, completeRequest } from '../lib/state.js';
let success = false; let success = false;
let error = null; let error = null;
@ -13,7 +13,7 @@
onMount(() => { onMount(() => {
window.setTimeout( window.setTimeout(
cleanupRequest, completeRequest,
// Extra 50ms so the window can finish disappearing before the redraw // Extra 50ms so the window can finish disappearing before the redraw
Math.min(5000, $appState.config.rehide_ms + 50), Math.min(5000, $appState.config.rehide_ms + 50),
) )
@ -22,7 +22,7 @@
<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.response.approval === 'Approved'} {#if $appState.currentRequest.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 +33,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.response.approval}! {$appState.currentRequest.approval}!
</div> </div>
</div> </div>