diff --git a/src-tauri/migrations/20240617142724_credential_split.sql b/src-tauri/migrations/20240617142724_credential_split.sql new file mode 100644 index 0000000..914663b --- /dev/null +++ b/src-tauri/migrations/20240617142724_credential_split.sql @@ -0,0 +1,52 @@ +-- 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 separate table per type of credential +CREATE TABLE aws_credentials ( + name TEXT UNIQUE NOT NULL, + access_key_id TEXT NOT NULL, + secret_key_enc BLOB NOT NULL, + nonce BLOB NOT NULL, + -- at some point we may want to offer to auto-rotate AWS keys, + -- so let's make sure to keep track of when they were created + created_at INTEGER NOT NULL +); + +INSERT INTO aws_credentials (name, access_key_id, secret_key_enc, nonce, created_at) +SELECT 'default', access_key_id, secret_key_enc, nonce, created_at +FROM credentials +ORDER BY created_at DESC +LIMIT 1; + +DROP TABLE credentials; + + +-- SSH keys are the new hotness +CREATE TABLE ssh_keys ( + name TEXT UNIQUE NOT NULL, + public_key BLOB NOT NULL, + private_key_enc BLOB NOT NULL, + nonce BLOB NOT NULL, + created_at INTEGER NOT NULL +); diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 2b38e25..e376c88 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -23,7 +23,7 @@ use tauri::menu::MenuItem; use crate::{ config::{self, AppConfig}, - credentials::Session, + credentials::AppSession, ipc, server::Server, errors::*, @@ -48,7 +48,7 @@ pub fn run() -> tauri::Result<()> { ipc::respond, ipc::get_session_status, ipc::signal_activity, - ipc::save_credentials, + ipc::save_aws_credential, ipc::get_config, ipc::save_config, ipc::launch_terminal, @@ -109,7 +109,7 @@ async fn setup(app: &mut App) -> Result<(), Box> { err => err?, }; - let session = Session::load(&pool).await?; + let app_session = AppSession::load(&pool).await?; Server::start(app.handle().clone())?; config::set_auto_launch(conf.start_on_login)?; @@ -128,12 +128,11 @@ async fn setup(app: &mut App) -> Result<(), Box> { .map(|names| names.split(':').any(|n| n == "GNOME")) .unwrap_or(false); - // if session is empty, this is probably the first launch, so don't autohide if !conf.start_minimized || is_first_launch { show_main_window(&app.handle())?; } - let state = AppState::new(conf, session, pool, setup_errors, desktop_is_gnome); + let state = AppState::new(conf, app_session, pool, setup_errors, desktop_is_gnome); app.manage(state); // make sure we do this after managing app state, so that it doesn't panic diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index c4c1b2d..a8c6aad 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -12,7 +12,6 @@ use clap::{ }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use crate::credentials::Credentials; use crate::errors::*; use crate::server::{Request, Response}; use crate::shortcuts::ShortcutAction; @@ -80,9 +79,10 @@ pub fn parser() -> Command<'static> { 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(), + let output = match make_request(&Request::GetAwsCredentials { base: *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(()) @@ -98,16 +98,17 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> { let mut cmd = ChildCommand::new(cmd_name); cmd.args(cmd_line); - match get_credentials(base)? { - Credentials::Base(creds) => { + match make_request(&Request::GetAwsCredentials { base })? { + Response::AwsBase(creds) => { cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id); cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key); }, - Credentials::Session(creds) => { + 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)] @@ -157,16 +158,6 @@ pub fn invoke_shortcut(args: &ArgMatches) -> Result<(), CliError> { } -fn get_credentials(base: bool) -> Result { - 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 { let mut data = serde_json::to_string(req).unwrap(); diff --git a/src-tauri/src/credentials/aws.rs b/src-tauri/src/credentials/aws.rs index 2c19ae7..f786c20 100644 --- a/src-tauri/src/credentials/aws.rs +++ b/src-tauri/src/credentials/aws.rs @@ -1,9 +1,15 @@ +use std::fmt::{self, Formatter}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use aws_smithy_types::date_time::{DateTime, Format}; +use chacha20poly1305::XNonce; use serde::{ Serialize, Deserialize, Serializer, Deserializer, }; +use serde::de::{self, Visitor}; use sqlx::SqlitePool; use super::{Crypto, PersistentCredential}; @@ -27,40 +33,46 @@ impl AwsBaseCredential { } impl PersistentCredential for AwsBaseCredential { - pub async fn save(&self, crypt: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> { + async fn save(&self, crypto: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> { let (nonce, ciphertext) = crypto.encrypt(self.secret_access_key.as_bytes())?; + let nonce_bytes = &nonce.as_slice(); sqlx::query!( "INSERT INTO aws_credentials ( name, - key_id, + access_key_id, secret_key_enc, nonce, - updated_at + created_at ) - VALUES ('main', ?, ?, ? strftime('%s')) + VALUES ('default', ?, ?, ?, strftime('%s')) ON CONFLICT DO UPDATE SET - key_id = excluded.key_id, + access_key_id = excluded.access_key_id, secret_key_enc = excluded.secret_key_enc, - nonce = excluded.nonce - updated_at = excluded.updated_at", + nonce = excluded.nonce, + created_at = excluded.created_at", self.access_key_id, ciphertext, - nonce, + nonce_bytes, ).execute(pool).await?; Ok(()) } - pub async fn load(crypt: &Crypto, pool: &SqlitePool) -> Result { - let row = sqlx::query!("SELECT * FROM aws_credentials WHERE name = 'main'") + async fn load(crypto: &Crypto, pool: &SqlitePool) -> Result { + let row = sqlx::query!("SELECT * FROM aws_credentials WHERE name = 'default'") .fetch_optional(pool) .await? - .ok_or(LoadCredentialsError::NoCredentials); + .ok_or(LoadCredentialsError::NoCredentials)?; + + // note: switch to try_from eventually + 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)?; - let secret_key = crypto.decrypt(&row.nonce, &row.secret_key_enc)?; let creds = Self { version: 1, - access_key_id: row.key_id, + access_key_id: row.access_key_id, secret_access_key: secret_key, }; Ok(creds) @@ -82,7 +94,7 @@ pub struct AwsSessionCredential { } impl AwsSessionCredential { - pub async fn from_base(base: &BaseCredentials) -> Result { + pub async fn from_base(base: &AwsBaseCredential) -> Result { let req_creds = aws_sdk_sts::Credentials::new( &base.access_key_id, &base.secret_access_key, @@ -116,7 +128,7 @@ impl AwsSessionCredential { .ok_or(GetSessionError::EmptyResponse)? .clone(); - let session_creds = SessionCredentials { + let session_creds = AwsSessionCredential { version: 1, access_key_id, secret_access_key, @@ -143,6 +155,9 @@ impl AwsSessionCredential { } +fn default_credentials_version() -> usize { 1 } + + struct DateTimeVisitor; impl<'de> Visitor<'de> for DateTimeVisitor { diff --git a/src-tauri/src/credentials/mod.rs b/src-tauri/src/credentials/mod.rs index ac9cd78..71e0d87 100644 --- a/src-tauri/src/credentials/mod.rs +++ b/src-tauri/src/credentials/mod.rs @@ -1,3 +1,5 @@ +use std::fmt::{Debug, Formatter}; + use argon2::{ Argon2, Algorithm, @@ -12,32 +14,25 @@ use chacha20poly1305::{ Aead, AeadCore, KeyInit, - Error as AeadError, generic_array::GenericArray, }, }; -use serde::{Serialize, Deserialize}; -use sqlx::{FromRow, SqlitePool}; +use serde::Deserialize; +use sqlx::SqlitePool; +use crate::errors::*; use crate::kv; mod aws; pub use aws::{AwsBaseCredential, AwsSessionCredential}; -pub enum CredentialKind { - AwsBase, - AwsSession, -} - - -pub trait PersistentCredential { +pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized { async fn load(crypt: &Crypto, pool: &SqlitePool) -> Result; async fn save(&self, crypt: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError>; } - -#[derive(Debug, Clone)] +#[derive(Clone, Debug)] pub enum AppSession { Unlocked { salt: [u8; 32], @@ -54,14 +49,14 @@ pub enum AppSession { impl AppSession { pub fn new(passphrase: &str) -> Result { let salt = Crypto::salt(); - let crypto = Crypto::new(passphrase, &salt); + let crypto = Crypto::new(passphrase, &salt)?; Ok(Self::Unlocked {salt, crypto}) } - pub fn unlock(self, passphrase: &str) -> Result { + 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::Unlocked {..} => return Err(UnlockError::NotLocked), Self::Locked {salt, verify_nonce, verify_blob} => (salt, verify_nonce, verify_blob), }; @@ -69,61 +64,78 @@ impl AppSession { .map_err(|e| CryptoError::Argon2(e))?; // if passphrase is incorrect, this will fail - let verify = crypto.decrypt(&nonce, &blob)?; + let _verify = crypto.decrypt(&nonce, &blob)?; - Ok(Self::Unlocked{crypto, salt}) + *self = Self::Unlocked {crypto, salt: *salt}; + Ok(()) } - pub async fn load(pool: &SqlitePool) -> Result { + pub async fn load(pool: &SqlitePool) -> Result { match kv::load_bytes_multi!(pool, "salt", "verify_nonce", "verify_blob").await? { - Some((salt, verify_nonce, verify_blob)) => { - Ok(Self::Locked {salt, verify_nonce, verify_blob}), + 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<(), LockError> { - let (salt, nonce, blob) = match self { + 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") - .map_err(|e| CryptoError::Aead(e))?; - (salt, nonce, blob) + let (nonce, blob) = crypto.encrypt(b"correct horse battery staple")?; + kv::save(pool, "salt", salt).await?; + kv::save(pool, "verify_nonce", &nonce.as_slice()).await?; + kv::save(pool, "verify_blob", &blob).await?; + }, + Self::Locked {salt, verify_nonce, verify_blob} => { + kv::save(pool, "salt", salt).await?; + kv::save(pool, "verify_nonce", &verify_nonce.as_slice()).await?; + kv::save(pool, "verify_blob", verify_blob).await?; }, - Self::Locked {salt, verify_nonce, verify_blob} => (salt, verify_nonce, verify_blob), // "saving" an empty session just means doing nothing - Self::Empty => return Ok(()), + Self::Empty => (), }; - kv::save(pool, "salt", salt).await?; - kv::save(pool, "verify_nonce", nonce).await?; - kv::save(pool, "verify_blob", blob).await?; - Ok(()) } - pub fn try_encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec), CryptoError> { - let crypto = match self { + pub fn try_get_crypto(&self) -> Result<&Crypto, GetCredentialsError> { + match self { Self::Empty => Err(GetCredentialsError::Empty), - Self::Locked => Err(GetCredentialsError::Locked), + Self::Locked {..} => Err(GetCredentialsError::Locked), + Self::Unlocked {crypto, ..} => Ok(crypto), + } + } + + pub fn try_encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec), GetCredentialsError> { + let crypto = match self { + Self::Empty => return Err(GetCredentialsError::Empty), + Self::Locked {..} => return Err(GetCredentialsError::Locked), Self::Unlocked {crypto, ..} => crypto, - }?; + }; let res = crypto.encrypt(data)?; Ok(res) } - pub fn try_decrypt(&self, nonce: XNonce, data: &[u8]) -> Result, CryptoError> { + pub fn try_decrypt(&self, nonce: XNonce, data: &[u8]) -> Result, GetCredentialsError> { let crypto = match self { - Self::Empty => Err(GetCredentialsError::Empty), - Self::Locked => Err(GetCredentialsError::Locked), + Self::Empty => return Err(GetCredentialsError::Empty), + Self::Locked {..} => return Err(GetCredentialsError::Locked), Self::Unlocked {crypto, ..} => crypto, - }?; - let res = crypto.decrypt(nonce, data)?; + }; + let res = crypto.decrypt(&nonce, data)?; Ok(res) } } +#[derive(Clone)] pub struct Crypto { cipher: XChaCha20Poly1305, } @@ -181,13 +193,20 @@ impl Crypto { salt } - fn encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec), AeadError> { + fn encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec), CryptoError> { 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, AeadError> { - self.cipher.decrypt(nonce, data) + fn decrypt(&self, nonce: &XNonce, data: &[u8]) -> Result, 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 {{ [...] }}") } } diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index cb8d548..0513543 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -208,6 +208,12 @@ pub enum GetCredentialsError { Locked, #[error("No credentials are known")] Empty, + #[error(transparent)] + Crypto(#[from] CryptoError), + #[error(transparent)] + Load(#[from] LoadCredentialsError), + #[error(transparent)] + GetSession(#[from] GetSessionError), } @@ -245,8 +251,8 @@ pub enum UnlockError { pub enum LockError { #[error("App is not unlocked")] NotUnlocked, - #[error("Database error: {0}")] - DbError(#[from] SqlxError), + #[error(transparent)] + LoadCredentials(#[from] LoadCredentialsError), #[error(transparent)] Setup(#[from] SetupError), #[error(transparent)] @@ -261,19 +267,23 @@ pub enum SaveCredentialsError { #[error("Database error: {0}")] DbError(#[from] SqlxError), #[error("Encryption error: {0}")] - Encryption(#[from] chacha20poly1305::Error), + Crypto(#[from] CryptoError), + #[error(transparent)] + Session(#[from] GetCredentialsError), } #[derive(Debug, ThisError, AsRefStr)] pub enum LoadCredentialsError { #[error("Database error: {0}")] DbError(#[from] SqlxError), - #[error("Encryption error: {0}")] - Encryption(#[from] chacha20poly1305::Error), + #[error("Invalid passphrase")] // pretty sure this is the only way decryption fails + Encryption(#[from] CryptoError), #[error("Credentials not found")] NoCredentials, - #[error("Could not decode credentials: {0}")] - Invalid(#[from] serde_json::Error), + #[error("Could not decode credential data")] + InvalidData, + #[error(transparent)] + LoadKv(#[from] LoadKvError), } @@ -292,6 +302,10 @@ pub enum CryptoError { Argon2(#[from] argon2::Error), #[error("Invalid passphrase")] // I think this is the only way decryption fails Aead(#[from] chacha20poly1305::aead::Error), + #[error("App is currently locked")] + Locked, + #[error("No passphrase has been specified")] + Empty, } @@ -409,6 +423,8 @@ impl_serialize_basic!(GetCredentialsError); impl_serialize_basic!(ClientInfoError); impl_serialize_basic!(WindowError); impl_serialize_basic!(LockError); +impl_serialize_basic!(SaveCredentialsError); +impl_serialize_basic!(LoadCredentialsError); impl Serialize for HandlerError { diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index e6e7ba7..ab33b8b 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -2,7 +2,7 @@ use serde::{Serialize, Deserialize}; use tauri::State; use crate::config::AppConfig; -use crate::credentials::{Session,BaseCredentials}; +use crate::credentials::{AppSession, AwsBaseCredential}; use crate::errors::*; use crate::clientinfo::Client; use crate::state::AppState; @@ -17,6 +17,31 @@ pub struct AwsRequestNotification { } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SshRequestNotification { + pub id: u64, + pub client: Client, + pub key_name: String, +} + + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum RequestNotification { + Aws(AwsRequestNotification), + Ssh(SshRequestNotification), +} + +impl RequestNotification { + pub fn new_aws(id: u64, client: Client, base: bool) -> Self { + Self::Aws(AwsRequestNotification {id, client, base}) + } + + pub fn new_ssh(id: u64, client: Client, key_name: String) -> Self { + Self::Ssh(SshRequestNotification {id, client, key_name}) + } +} + + #[derive(Debug, Serialize, Deserialize)] pub struct RequestResponse { pub id: u64, @@ -46,11 +71,11 @@ pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Resul #[tauri::command] pub async fn get_session_status(app_state: State<'_, AppState>) -> Result { - let session = app_state.session.read().await; + let session = app_state.app_session.read().await; let status = match *session { - Session::Locked(_) => "locked".into(), - Session::Unlocked{..} => "unlocked".into(), - Session::Empty => "empty".into() + AppSession::Locked{..} => "locked".into(), + AppSession::Unlocked{..} => "unlocked".into(), + AppSession::Empty => "empty".into(), }; Ok(status) } @@ -64,12 +89,11 @@ pub async fn signal_activity(app_state: State<'_, AppState>) -> Result<(), ()> { #[tauri::command] -pub async fn save_credentials( - credentials: BaseCredentials, - passphrase: String, +pub async fn save_aws_credential( + credential: AwsBaseCredential, app_state: State<'_, AppState> -) -> Result<(), UnlockError> { - app_state.new_creds(credentials, &passphrase).await +) -> Result<(), SaveCredentialsError> { + app_state.save_creds(credential).await } diff --git a/src-tauri/src/kv.rs b/src-tauri/src/kv.rs index f4a0732..cb5039e 100644 --- a/src-tauri/src/kv.rs +++ b/src-tauri/src/kv.rs @@ -44,45 +44,37 @@ pub async fn load_bytes(pool: &SqlitePool, name: &str) -> Result> } -// pub async fn load_bytes_multi( -// pool: &SqlitePool, -// names: [&str; N], -// ) -> Result; N]>, sqlx::Error> { -// // just use multiple queries, who cares -// let res: [Vec; N] = Default::default(); -// for (i, name) in names.as_slice().iter().enumerate() { -// match load_bytes(pool, name).await? { -// Some(bytes) => res[i] = bytes, -// None => return Ok(None), -// } -// } -// Ok(res); -// } - - macro_rules! load_bytes_multi { ( $pool:ident, $($name:literal),* ) => { - // wrap everything up in an immediately-invoked closure for easy short-circuiting - (|| { - // a tuple, with one item for each repetition of $name - ( - // repeat this match block for every name - $( - // load_bytes returns Result>, the Result is handled by - // the ? and we match on the Option - match load_bytes(pool, $name)? { - Some(v) => v, - None => return Ok(None) - }, - )* + // 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>, 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 { // ( diff --git a/src-tauri/src/server/mod.rs b/src-tauri/src/server/mod.rs index 66b4816..1b1947d 100644 --- a/src-tauri/src/server/mod.rs +++ b/src-tauri/src/server/mod.rs @@ -7,8 +7,11 @@ use tauri::{AppHandle, Manager}; use crate::errors::*; use crate::clientinfo::{self, Client}; -use crate::credentials::Credentials; -use crate::ipc::{Approval, AwsRequestNotification}; +use crate::credentials::{ + AwsBaseCredential, + AwsSessionCredential, +}; +use crate::ipc::{Approval, RequestNotification}; use crate::state::AppState; use crate::shortcuts::{self, ShortcutAction}; @@ -40,7 +43,8 @@ pub enum Request { #[derive(Debug, Serialize, Deserialize)] pub enum Response { - Aws(Credentials), + AwsBase(AwsBaseCredential), + AwsSession(AwsSessionCredential), Empty, } @@ -127,7 +131,7 @@ async fn get_aws_credentials( // 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}; + let notification = RequestNotification::new_aws(request_id, client, base); app_handle.emit("credentials-request", ¬ification)?; let response = tokio::select! { @@ -141,12 +145,12 @@ async fn get_aws_credentials( match response.approval { Approval::Approved => { if response.base { - let creds = state.base_creds_cloned().await?; - Ok(Response::Aws(Credentials::Base(creds))) + let creds = state.get_aws_base().await?; + Ok(Response::AwsBase(creds)) } else { - let creds = state.session_creds_cloned().await?; - Ok(Response::Aws(Credentials::Session(creds))) + let creds = state.get_aws_session().await?; + Ok(Response::AwsSession(creds.clone())) } }, Approval::Denied => Err(HandlerError::Denied), diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 4ac1ff4..b46dc93 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -3,7 +3,7 @@ use std::time::Duration; use time::OffsetDateTime; use tokio::{ - sync::RwLock, + sync::{RwLock, RwLockReadGuard}, sync::oneshot::{self, Sender}, }; use sqlx::SqlitePool; @@ -14,12 +14,12 @@ use tauri::{ use crate::app; use crate::credentials::{ - Session, - BaseCredentials, - SessionCredentials, + AppSession, + AwsSessionCredential, }; use crate::{config, config::AppConfig}; -use crate::ipc::{self, Approval, RequestResponse}; +use crate::credentials::{AwsBaseCredential, PersistentCredential}; +use crate::ipc::{self, RequestResponse}; use crate::errors::*; use crate::shortcuts; @@ -101,7 +101,8 @@ impl VisibilityLease { #[derive(Debug)] pub struct AppState { pub config: RwLock, - pub session: RwLock, + pub app_session: RwLock, + pub aws_session: RwLock>, pub last_activity: RwLock, pub request_count: RwLock, pub waiting_requests: RwLock>>, @@ -116,14 +117,15 @@ pub struct AppState { impl AppState { pub fn new( config: AppConfig, - session: Session, + app_session: AppSession, pool: SqlitePool, setup_errors: Vec, desktop_is_gnome: bool, ) -> AppState { AppState { config: RwLock::new(config), - session: RwLock::new(session), + app_session: RwLock::new(app_session), + aws_session: RwLock::new(None), last_activity: RwLock::new(OffsetDateTime::now_utc()), request_count: RwLock::new(0), waiting_requests: RwLock::new(HashMap::new()), @@ -135,13 +137,12 @@ impl AppState { } } - pub async fn new_creds(&self, base_creds: BaseCredentials, passphrase: &str) -> Result<(), UnlockError> { - let locked = base_creds.encrypt(passphrase)?; - // do this first so that if it fails we don't save bad credentials - self.new_session(base_creds).await?; - locked.save(&self.pool).await?; - - Ok(()) + pub async fn save_creds(&self, credential: C) -> Result<(), SaveCredentialsError> + where C: PersistentCredential + { + let session = self.app_session.read().await; + let crypto = session.try_get_crypto()?; + credential.save(crypto, &self.pool).await } pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> { @@ -171,6 +172,7 @@ impl AppState { c }; + let mut waiting_requests = self.waiting_requests.write().await; waiting_requests.insert(*count, sender); // `count` is the request id *count @@ -187,11 +189,6 @@ impl AppState { } 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; waiting_requests .remove(&response.id) @@ -201,24 +198,17 @@ impl AppState { } pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> { - let base_creds = match *self.session.read().await { - Session::Empty => {return Err(UnlockError::NoCredentials);}, - Session::Unlocked{..} => {return Err(UnlockError::NotLocked);}, - Session::Locked(ref locked) => locked.decrypt(passphrase)?, - }; - // Read lock is dropped here, so this doesn't deadlock - self.new_session(base_creds).await?; - - Ok(()) + let mut session = self.app_session.write().await; + session.unlock(passphrase) } pub async fn lock(&self) -> Result<(), LockError> { - let mut session = self.session.write().await; + let mut session = self.app_session.write().await; match *session { - Session::Empty => Err(LockError::NotUnlocked), - Session::Locked(_) => Err(LockError::NotUnlocked), - Session::Unlocked{..} => { - *session = Session::load(&self.pool).await?; + 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::)?; @@ -228,6 +218,29 @@ impl AppState { } } + pub async fn get_aws_base(&self) -> Result { + let app_session = self.app_session.read().await; + let crypto = app_session.try_get_crypto()?; + let creds = AwsBaseCredential::load(crypto, &self.pool).await?; + Ok(creds) + } + + pub async fn get_aws_session(&self) -> Result, GetCredentialsError> { + // yes, this sometimes results in double-fetching base credentials from disk + // I'm done trying to be optimal + { + let mut aws_session = self.aws_session.write().await; + if aws_session.is_none() || aws_session.as_ref().unwrap().is_expired() { + let base_creds = self.get_aws_base().await?; + *aws_session = Some(AwsSessionCredential::from_base(&base_creds).await?); + } + } + + // we know this is safe, because we juse made sure of it + let s = RwLockReadGuard::map(self.aws_session.read().await, |opt| opt.as_ref().unwrap()); + Ok(s) + } + pub async fn signal_activity(&self) { let mut last_activity = self.last_activity.write().await; *last_activity = OffsetDateTime::now_utc(); @@ -245,27 +258,8 @@ impl AppState { } pub async fn is_unlocked(&self) -> bool { - let session = self.session.read().await; - matches!(*session, Session::Unlocked{..}) - } - - pub async fn base_creds_cloned(&self) -> Result { - let app_session = self.session.read().await; - let (base, _session) = app_session.try_get()?; - Ok(base.clone()) - } - - pub async fn session_creds_cloned(&self) -> Result { - let app_session = self.session.read().await; - let (_base, session) = app_session.try_get()?; - Ok(session.clone()) - } - - async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> { - let session = SessionCredentials::from_base(&base).await?; - let mut app_session = self.session.write().await; - *app_session = Session::Unlocked {base, session}; - Ok(()) + let session = self.app_session.read().await; + matches!(*session, AppSession::Unlocked{..}) } pub async fn register_terminal_request(&self) -> Result<(), ()> { diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 3283dd0..2082fae 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -45,22 +45,19 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> { lease.release(); } - // more lock-management - { - 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); - } + // 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().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().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); } let res = match cmd.spawn() {