still in progress

This commit is contained in:
Joseph Montanaro 2024-06-25 15:19:29 -04:00
parent 9928996fab
commit 8c668e51a6
12 changed files with 1620 additions and 1138 deletions

2101
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -28,12 +28,11 @@ 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"] } tokio = { version = ">=1.19", features = ["full"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] }
sysinfo = "0.26.8" sysinfo = "0.26.8"
aws-types = "0.52.0" aws-config = "1.5.3"
aws-sdk-sts = "0.22.0" aws-types = "1.3.2"
aws-smithy-types = "0.52.0" aws-sdk-sts = "1.33.0"
aws-config = "0.52.0" aws-smithy-types = "1.2.0"
thiserror = "1.0.38" thiserror = "1.0.38"
once_cell = "1.16.0" once_cell = "1.16.0"
strum = "0.24" strum = "0.24"
@ -53,6 +52,8 @@ rfd = "0.14.1"
ssh-agent-lib = "0.4.0" 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"
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "uuid"] }
[features] [features]
# by default Tauri runs in production mode # by default Tauri runs in production mode

View File

@ -22,24 +22,49 @@ UNION ALL
SELECT 'verify_blob', secret_key_enc FROM latest_creds; SELECT 'verify_blob', secret_key_enc FROM latest_creds;
-- Credentials are now going to be stored in a separate table per type of credential -- Credentials are now going to be stored in a main table
CREATE TABLE aws_credentials ( -- plus ancillary tables for type-specific data
-- stash existing AWS creds in temporary table so that we can remake it
CREATE TABLE aws_tmp (id, access_key_id, secret_key_enc, nonce, created_at);
INSERT INTO aws_tmp
SELECT randomblob(16), access_key_id, secret_key_enc, nonce, created_at
FROM credentials
ORDER BY created_at DESC
-- we only ever used one at a time in the past
LIMIT 1;
-- new master credentials table
DROP TABLE credentials;
CREATE TABLE credentials (
-- id is a UUID so we can generate it on the frontend
id BLOB UNIQUE NOT NULL,
name TEXT UNIQUE NOT NULL, name TEXT UNIQUE NOT NULL,
access_key_id TEXT NOT NULL, type 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 created_at INTEGER NOT NULL
); );
INSERT INTO aws_credentials (name, access_key_id, secret_key_enc, nonce, created_at) -- populate with basic data from existing AWS credential
SELECT 'default', access_key_id, secret_key_enc, nonce, created_at INSERT INTO credentials (id, name, type, created_at)
FROM credentials SELECT id, 'default', 'aws', created_at FROM aws_tmp;
ORDER BY created_at DESC
LIMIT 1;
DROP TABLE credentials; -- new AWS-specific table
CREATE TABLE aws_credentials (
id BLOB UNIQUE NOT NULL,
access_key_id TEXT NOT NULL,
secret_key_enc BLOB NOT NULL,
nonce BLOB NOT NULL,
FOREIGN KEY(id) REFERENCES credentials(id) ON DELETE CASCADE
);
-- populate with AWS-specific data from existing credential
INSERT INTO aws_credentials (id, access_key_id, secret_key_enc, nonce)
SELECT id, access_key_id, secret_key_enc, nonce
FROM aws_tmp;
-- done with this now
DROP TABLE aws_tmp;
-- SSH keys are the new hotness -- SSH keys are the new hotness
@ -47,6 +72,5 @@ CREATE TABLE ssh_keys (
name TEXT UNIQUE NOT NULL, name TEXT UNIQUE NOT NULL,
public_key BLOB NOT NULL, public_key BLOB NOT NULL,
private_key_enc BLOB NOT NULL, private_key_enc BLOB NOT NULL,
nonce BLOB NOT NULL, nonce BLOB NOT NULL
created_at INTEGER NOT NULL
); );

View File

@ -45,10 +45,14 @@ pub fn run() -> tauri::Result<()> {
.plugin(tauri_plugin_global_shortcut::Builder::default().build()) .plugin(tauri_plugin_global_shortcut::Builder::default().build())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
ipc::unlock, ipc::unlock,
ipc::lock,
ipc::set_passphrase,
ipc::respond, ipc::respond,
ipc::get_session_status, ipc::get_session_status,
ipc::signal_activity, ipc::signal_activity,
ipc::save_aws_credential, ipc::save_credential,
ipc::delete_credential,
ipc::list_credentials,
ipc::get_config, ipc::get_config,
ipc::save_config, ipc::save_config,
ipc::launch_terminal, ipc::launch_terminal,

View File

@ -1,6 +1,7 @@
use std::fmt::{self, Formatter}; use std::fmt::{self, Formatter};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use aws_config::BehaviorVersion;
use aws_smithy_types::date_time::{DateTime, Format}; use aws_smithy_types::date_time::{DateTime, Format};
use chacha20poly1305::XNonce; use chacha20poly1305::XNonce;
use serde::{ use serde::{
@ -10,14 +11,21 @@ use serde::{
Deserializer, Deserializer,
}; };
use serde::de::{self, Visitor}; use serde::de::{self, Visitor};
use sqlx::SqlitePool; use sqlx::{
SqlitePool,
types::Uuid,
};
use sqlx::error::{
Error as SqlxError,
};
use tokio_stream::StreamExt;
use super::{Crypto, PersistentCredential}; use super::{Credential, Crypto, SaveCredential, PersistentCredential};
use crate::errors::*; use crate::errors::*;
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
pub struct AwsBaseCredential { pub struct AwsBaseCredential {
#[serde(default = "default_credentials_version")] #[serde(default = "default_credentials_version")]
@ -26,6 +34,7 @@ pub struct AwsBaseCredential {
pub secret_access_key: String, pub secret_access_key: String,
} }
impl AwsBaseCredential { impl AwsBaseCredential {
pub fn new(access_key_id: String, secret_access_key: String) -> Self { pub fn new(access_key_id: String, secret_access_key: String) -> Self {
Self {version: 1, access_key_id, secret_access_key} Self {version: 1, access_key_id, secret_access_key}
@ -33,54 +42,89 @@ impl AwsBaseCredential {
} }
impl PersistentCredential for AwsBaseCredential { impl PersistentCredential for AwsBaseCredential {
async fn save(&self, crypto: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> { async fn save(&self, id: &Uuid, name: &str, crypto: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
let (nonce, ciphertext) = crypto.encrypt(self.secret_access_key.as_bytes())?; let (nonce, ciphertext) = crypto.encrypt(self.secret_access_key.as_bytes())?;
let nonce_bytes = &nonce.as_slice(); let nonce_bytes = &nonce.as_slice();
sqlx::query!( let res = sqlx::query!(
"INSERT INTO aws_credentials ( "INSERT INTO credentials (id, name, type, created_at)
name, VALUES (?, ?, 'aws', strftime('%s'))
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
type = excluded.type,
created_at = excluded.created_at;
INSERT OR REPLACE INTO aws_credentials (
id,
access_key_id, access_key_id,
secret_key_enc, secret_key_enc,
nonce, nonce
created_at
) )
VALUES ('default', ?, ?, ?, strftime('%s')) VALUES (?, ?, ?, ?);",
ON CONFLICT DO UPDATE SET id,
access_key_id = excluded.access_key_id, name,
secret_key_enc = excluded.secret_key_enc, id, // for the second query
nonce = excluded.nonce,
created_at = excluded.created_at",
self.access_key_id, self.access_key_id,
ciphertext, ciphertext,
nonce_bytes, nonce_bytes,
).execute(pool).await?; ).execute(pool).await;
Ok(()) match res {
Err(SqlxError::Database(e)) if e.code().as_deref() == Some("2067") => Err(SaveCredentialsError::Duplicate),
Err(e) => Err(SaveCredentialsError::DbError(e)),
Ok(_) => Ok(())
}
} }
async fn load(crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> { async fn load(name: &str, crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
let row = sqlx::query!("SELECT * FROM aws_credentials WHERE name = 'default'") let row = sqlx::query!(
.fetch_optional(pool) "SELECT c.name, a.access_key_id, a.secret_key_enc, a.nonce
FROM credentials c JOIN aws_credentials a ON a.id = c.id
WHERE c.name = ?",
name
).fetch_optional(pool)
.await? .await?
.ok_or(LoadCredentialsError::NoCredentials)?; .ok_or(LoadCredentialsError::NoCredentials)?;
// note: switch to try_from eventually
let nonce = XNonce::clone_from_slice(&row.nonce); let nonce = XNonce::clone_from_slice(&row.nonce);
let secret_key_bytes = crypto.decrypt(&nonce, &row.secret_key_enc)?; let secret_key_bytes = crypto.decrypt(&nonce, &row.secret_key_enc)?;
let secret_key = String::from_utf8(secret_key_bytes) let secret_key = String::from_utf8(secret_key_bytes)
.map_err(|_| LoadCredentialsError::InvalidData)?; .map_err(|_| LoadCredentialsError::InvalidData)?;
let creds = Self { Ok(AwsBaseCredential::new(row.access_key_id, secret_key))
version: 1, }
access_key_id: row.access_key_id,
secret_access_key: secret_key, async fn list(crypto: &Crypto, pool: &SqlitePool) -> Result<Vec<SaveCredential>, LoadCredentialsError> {
}; let mut rows = sqlx::query!(
"SELECT c.id, c.name, a.access_key_id, a.secret_key_enc, a.nonce
FROM credentials c JOIN aws_credentials a ON a.id = c.id"
).fetch(pool);
let mut creds = Vec::new();
while let Some(row) = rows.try_next().await? {
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 aws = AwsBaseCredential::new(row.access_key_id, secret_key);
let id = Uuid::from_slice(&row.id)
.map_err(|_| LoadCredentialsError::InvalidData)?;
let cred = SaveCredential {
id,
name: row.name,
credential: Credential::AwsBase(aws),
};
creds.push(cred);
}
Ok(creds) Ok(creds)
} }
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
pub struct AwsSessionCredential { pub struct AwsSessionCredential {
#[serde(default = "default_credentials_version")] #[serde(default = "default_credentials_version")]
@ -95,14 +139,14 @@ pub struct AwsSessionCredential {
impl AwsSessionCredential { impl AwsSessionCredential {
pub async fn from_base(base: &AwsBaseCredential) -> Result<Self, GetSessionError> { pub async fn from_base(base: &AwsBaseCredential) -> Result<Self, GetSessionError> {
let req_creds = aws_sdk_sts::Credentials::new( let req_creds = aws_sdk_sts::config::Credentials::new(
&base.access_key_id, &base.access_key_id,
&base.secret_access_key, &base.secret_access_key,
None, // token None, // token
None, //expiration None, //expiration
"Creddy", // "provider name" apparently "Creddy", // "provider name" apparently
); );
let config = aws_config::from_env() let config = aws_config::defaults(BehaviorVersion::latest())
.credentials_provider(req_creds) .credentials_provider(req_creds)
.load() .load()
.await; .await;
@ -113,27 +157,14 @@ impl AwsSessionCredential {
.send() .send()
.await?; .await?;
let aws_session = resp.credentials().ok_or(GetSessionError::EmptyResponse)?; let aws_session = resp.credentials.ok_or(GetSessionError::EmptyResponse)?;
let access_key_id = aws_session.access_key_id()
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let secret_access_key = aws_session.secret_access_key()
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let session_token = aws_session.session_token()
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let expiration = aws_session.expiration()
.ok_or(GetSessionError::EmptyResponse)?
.clone();
let session_creds = AwsSessionCredential { let session_creds = AwsSessionCredential {
version: 1, version: 1,
access_key_id, access_key_id: aws_session.access_key_id,
secret_access_key, secret_access_key: aws_session.secret_access_key,
session_token, session_token: aws_session.session_token,
expiration, expiration: aws_session.expiration,
}; };
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -187,3 +218,128 @@ where S: Serializer
let time_str = exp.fmt(Format::DateTime).unwrap(); let time_str = exp.fmt(Format::DateTime).unwrap();
serializer.serialize_str(&time_str) serializer.serialize_str(&time_str)
} }
#[cfg(test)]
mod tests {
use super::*;
fn test_creds() -> AwsBaseCredential {
AwsBaseCredential::new(
"AKIAIOSFODNN7EXAMPLE".into(),
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
)
}
fn test_creds_2() -> AwsBaseCredential {
AwsBaseCredential::new(
"AKIAIOSFODNN7EXAMPL2".into(),
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKE2".into(),
)
}
fn test_uuid() -> Uuid {
Uuid::try_parse("00000000-0000-0000-0000-000000000000").unwrap()
}
fn test_uuid_2() -> Uuid {
Uuid::try_parse("ffffffff-ffff-ffff-ffff-ffffffffffff").unwrap()
}
fn test_uuid_random() -> Uuid {
let bytes = Crypto::salt();
Uuid::from_slice(&bytes[..16]).unwrap()
}
#[sqlx::test]
async fn test_save(pool: SqlitePool) {
let crypt = Crypto::random();
test_creds().save(&test_uuid_random(), "test", &crypt, &pool).await
.expect("Failed to save AWS credentials");
}
#[sqlx::test(fixtures("aws_credentials"))]
async fn test_overwrite(pool: SqlitePool) {
let crypt = Crypto::fixed();
let creds = test_creds_2();
// overwite original creds with different test data
creds.save(&test_uuid(), "test", &crypt, &pool).await
.expect("Failed to update AWS credentials");
// make sure update went through
let loaded = AwsBaseCredential::load("test", &crypt, &pool).await.unwrap();
assert_eq!(creds, loaded);
}
#[sqlx::test(fixtures("aws_credentials"))]
async fn test_duplicate_name(pool: SqlitePool) {
let crypt = Crypto::random();
let id = test_uuid_random();
let resp = test_creds().save(&id, "test", &crypt, &pool).await;
if !matches!(resp, Err(SaveCredentialsError::Duplicate)) {
panic!("Attempt to create duplicate entry returned {resp:?}")
}
}
#[sqlx::test(fixtures("aws_credentials"))]
async fn test_load(pool: SqlitePool) {
let crypt = Crypto::fixed();
let loaded = AwsBaseCredential::load("test", &crypt, &pool).await.unwrap();
assert_eq!(test_creds(), loaded);
}
#[sqlx::test]
async fn test_save_load(pool: SqlitePool) {
let crypt = Crypto::random();
let creds = test_creds();
creds.save(&test_uuid_random(), "test", &crypt, &pool).await.unwrap();
let loaded = AwsBaseCredential::load("test", &crypt, &pool).await.unwrap();
assert_eq!(creds, loaded);
}
#[sqlx::test(fixtures("aws_credentials"))]
async fn test_list(pool: SqlitePool) {
let crypt = Crypto::fixed();
let list = AwsBaseCredential::list(&crypt, &pool).await
.expect("Failed to list AWS credentials");
let first = SaveCredential {
id: test_uuid(),
name: "test".into(),
credential: Credential::AwsBase(test_creds()),
};
assert_eq!(&first, &list[0]);
let second = SaveCredential {
id: test_uuid_2(),
name: "test2".into(),
credential: Credential::AwsBase(test_creds_2()),
};
assert_eq!(&second, &list[1]);
}
#[sqlx::test(fixtures("aws_credentials"))]
async fn test_rekey(pool: SqlitePool) {
let old_crypt = Crypto::fixed();
let orig = AwsBaseCredential::list(&old_crypt, &pool).await.unwrap();
let new_crypt = Crypto::random();
AwsBaseCredential::rekey(&old_crypt, &new_crypt, &pool).await
.expect("Failed to re-key AWS credentials");
let rekeyed = AwsBaseCredential::list(&new_crypt, &pool).await.unwrap();
for (before, after) in orig.iter().zip(rekeyed.iter()) {
assert_eq!(before, after);
}
}
}

View File

@ -0,0 +1,19 @@
INSERT INTO credentials (id, name, type, created_at)
VALUES
(X'00000000000000000000000000000000', 'test', 'aws', strftime('%s')),
(X'ffffffffffffffffffffffffffffffff', 'test2', 'aws', strftime('%s'));
INSERT INTO aws_credentials (id, access_key_id, secret_key_enc, nonce)
VALUES
(
X'00000000000000000000000000000000',
'AKIAIOSFODNN7EXAMPLE',
X'B09ACDADD07E295A3FD9146D8D3672FA5C2518BFB15CF039E68820C42EFD3BC3BE3156ACF438C2C957EC113EF8617DBC71790EAFE39B3DE8',
X'DB777F2C6315DC0E12ADF322256E69D09D7FB586AAE614A6'
),
(
X'ffffffffffffffffffffffffffffffff',
'AKIAIOSFODNN7EXAMPL2',
X'ED6125FF40EF6F61929DF5FFD7141CD2B5A302A51C20152156477F8CC77980C614AB1B212AC06983F3CED35C4F3C54D4EE38964859930FBF',
X'962396B78DAA98DFDCC0AC0C9B7D688EC121F5759EBA790A'
);

View File

@ -1,4 +1,4 @@
use std::fmt::{Debug, Formatter}; use std::fmt::{self, Debug, Formatter};
use argon2::{ use argon2::{
Argon2, Argon2,
@ -17,8 +17,15 @@ use chacha20poly1305::{
generic_array::GenericArray, generic_array::GenericArray,
}, },
}; };
use serde::Deserialize; use serde::{
Serialize,
Deserialize,
Serializer,
Deserializer,
};
use serde::de::{self, Visitor};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use sqlx::types::Uuid;
use crate::errors::*; use crate::errors::*;
use crate::kv; use crate::kv;
@ -27,11 +34,73 @@ mod aws;
pub use aws::{AwsBaseCredential, AwsSessionCredential}; pub use aws::{AwsBaseCredential, AwsSessionCredential};
pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized { #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
async fn load(crypt: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError>; pub enum Credential {
async fn save(&self, crypt: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError>; AwsBase(AwsBaseCredential),
AwsSession(AwsSessionCredential),
} }
// we need a special type for listing structs because
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct SaveCredential {
#[serde(serialize_with = "serialize_uuid")]
#[serde(deserialize_with = "deserialize_uuid")]
id: Uuid, // UUID so it can be generated on the frontend
name: String, // user-facing identifier so it can be changed
credential: Credential,
}
impl SaveCredential {
pub async fn save(&self, crypt: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
let cred = match &self.credential {
Credential::AwsBase(b) => b,
Credential::AwsSession(_) => return Err(SaveCredentialsError::NotPersistent),
};
cred.save(&self.id, &self.name, crypt, pool).await
}
}
fn serialize_uuid<S: Serializer>(u: &Uuid, s: S) -> Result<S::Ok, S::Error> {
let mut buf = Vec::new();
s.serialize_str(u.as_hyphenated().encode_lower(&mut buf))
}
struct UuidVisitor;
impl<'de> Visitor<'de> for UuidVisitor {
type Value = Uuid;
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
write!(formatter, "a hyphenated UUID")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Uuid, E> {
Uuid::try_parse(v)
.map_err(|_| E::custom(format!("Could not interpret string as UUID: {v}")))
}
}
fn deserialize_uuid<'de, D: Deserializer<'de>>(ds: D) -> Result<Uuid, D::Error> {
ds.deserialize_str(UuidVisitor)
}
pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized {
async fn load(name: &str, crypt: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError>;
async fn list(crypt: &Crypto, pool: &SqlitePool) -> Result<Vec<SaveCredential>, LoadCredentialsError>;
async fn save(&self, id: &Uuid, name: &str, crypt: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError>;
async fn rekey(old: &Crypto, new: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
for cred in Self::list(old, pool).await? {
cred.save(new, pool).await?;
}
Ok(())
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum AppSession { pub enum AppSession {
Unlocked { Unlocked {
@ -89,14 +158,14 @@ impl AppSession {
match self { match self {
Self::Unlocked {salt, crypto} => { Self::Unlocked {salt, crypto} => {
let (nonce, blob) = crypto.encrypt(b"correct horse battery staple")?; let (nonce, blob) = crypto.encrypt(b"correct horse battery staple")?;
kv::save(pool, "salt", salt).await?; kv::save_bytes(pool, "salt", salt).await?;
kv::save(pool, "verify_nonce", &nonce.as_slice()).await?; kv::save_bytes(pool, "verify_nonce", &nonce.as_slice()).await?;
kv::save(pool, "verify_blob", &blob).await?; kv::save_bytes(pool, "verify_blob", &blob).await?;
}, },
Self::Locked {salt, verify_nonce, verify_blob} => { Self::Locked {salt, verify_nonce, verify_blob} => {
kv::save(pool, "salt", salt).await?; kv::save_bytes(pool, "salt", salt).await?;
kv::save(pool, "verify_nonce", &verify_nonce.as_slice()).await?; kv::save_bytes(pool, "verify_nonce", &verify_nonce.as_slice()).await?;
kv::save(pool, "verify_blob", verify_blob).await?; kv::save_bytes(pool, "verify_blob", verify_blob).await?;
}, },
// "saving" an empty session just means doing nothing // "saving" an empty session just means doing nothing
Self::Empty => (), Self::Empty => (),
@ -187,6 +256,25 @@ impl Crypto {
Ok(Crypto { cipher }) Ok(Crypto { cipher })
} }
#[cfg(test)]
pub fn random() -> Crypto {
// salt and key are the same length, so we can just use this
let key = Crypto::salt();
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
Crypto { cipher }
}
#[cfg(test)]
pub fn fixed() -> Crypto {
let key = [
1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
];
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
Crypto { cipher }
}
fn salt() -> [u8; 32] { fn salt() -> [u8; 32] {
let mut salt = [0; 32]; let mut salt = [0; 32];
OsRng.fill_bytes(&mut salt); OsRng.fill_bytes(&mut salt);
@ -210,3 +298,16 @@ impl Debug for Crypto {
write!(f, "Crypto {{ [...] }}") write!(f, "Crypto {{ [...] }}")
} }
} }
// #[cfg(test)]
// mod tests {
// use super::*;
// #[sqlx::test(fixtures("uuid_test"))]
// async fn save_uuid(pool: SqlitePool) {
// let u = Uuid::try_parse("7140b90c-bfbd-4394-9008-01b94f94ecf8").unwrap();
// sqlx::query!("INSERT INTO uuids (uuid) VALUES (?)", u).execute(pool).unwrap();
// panic!("done, go check db");
// }
// }

View File

@ -6,8 +6,9 @@ use strum_macros::AsRefStr;
use thiserror::Error as ThisError; use thiserror::Error as ThisError;
use aws_sdk_sts::{ use aws_sdk_sts::{
types::SdkError as AwsSdkError, error::SdkError as AwsSdkError,
error::GetSessionTokenError, operation::get_session_token::GetSessionTokenError,
error::ProvideErrorMetadata,
}; };
use rfd::{ use rfd::{
AsyncMessageDialog, AsyncMessageDialog,
@ -270,6 +271,16 @@ pub enum SaveCredentialsError {
Crypto(#[from] CryptoError), Crypto(#[from] CryptoError),
#[error(transparent)] #[error(transparent)]
Session(#[from] GetCredentialsError), Session(#[from] GetCredentialsError),
#[error("App is locked")]
Locked,
#[error("Credential is temporary and cannot be saved")]
NotPersistent,
#[error("A credential with that name already exists")]
Duplicate,
// rekeying is fundamentally a save operation,
// but involves loading in order to re-save
#[error(transparent)]
LoadCredentials(#[from] LoadCredentialsError),
} }
#[derive(Debug, ThisError, AsRefStr)] #[derive(Debug, ThisError, AsRefStr)]

View File

@ -1,8 +1,12 @@
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use sqlx::types::Uuid;
use tauri::State; use tauri::State;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::credentials::{AppSession, AwsBaseCredential}; use crate::credentials::{
AppSession,
SaveCredential
};
use crate::errors::*; use crate::errors::*;
use crate::clientinfo::Client; use crate::clientinfo::Client;
use crate::state::AppState; use crate::state::AppState;
@ -26,6 +30,7 @@ pub struct SshRequestNotification {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum RequestNotification { pub enum RequestNotification {
Aws(AwsRequestNotification), Aws(AwsRequestNotification),
Ssh(SshRequestNotification), Ssh(SshRequestNotification),
@ -69,6 +74,18 @@ pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Resul
} }
#[tauri::command]
pub async fn lock(app_state: State<'_, AppState>) -> Result<(), LockError> {
app_state.lock().await
}
#[tauri::command]
pub async fn set_passphrase(passphrase: &str, app_state: State<'_, AppState>) -> Result<(), SaveCredentialsError> {
app_state.set_passphrase(passphrase).await
}
#[tauri::command] #[tauri::command]
pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String, ()> { pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String, ()> {
let session = app_state.app_session.read().await; let session = app_state.app_session.read().await;
@ -89,11 +106,25 @@ pub async fn signal_activity(app_state: State<'_, AppState>) -> Result<(), ()> {
#[tauri::command] #[tauri::command]
pub async fn save_aws_credential( pub async fn save_credential(
credential: AwsBaseCredential, cred: SaveCredential,
app_state: State<'_, AppState> app_state: State<'_, AppState>
) -> Result<(), SaveCredentialsError> { ) -> Result<(), SaveCredentialsError> {
app_state.save_creds(credential).await app_state.save_credential(cred).await
}
#[tauri::command]
pub async fn delete_credential(id: &str, app_state: State<'_, AppState>) -> Result<(), SaveCredentialsError> {
let id = Uuid::try_parse(id)
.map_err(|_| LoadCredentialsError::NoCredentials)?;
app_state.delete_credential(&id).await
}
#[tauri::command]
pub async fn list_credentials(app_state: State<'_, AppState>) -> Result<Vec<SaveCredential>, GetCredentialsError> {
app_state.list_credentials().await
} }

View File

@ -132,7 +132,7 @@ async fn get_aws_credentials(
// so we bundle it all up in an async block and return a Result so we can handle errors // so we bundle it all up in an async block and return a Result so we can handle errors
let proceed = async { let proceed = async {
let notification = RequestNotification::new_aws(request_id, client, base); let notification = RequestNotification::new_aws(request_id, client, base);
app_handle.emit("credentials-request", &notification)?; app_handle.emit("credential-request", &notification)?;
let response = tokio::select! { let response = tokio::select! {
r = chan_recv => r?, r = chan_recv => r?,
@ -145,11 +145,11 @@ async fn get_aws_credentials(
match response.approval { match response.approval {
Approval::Approved => { Approval::Approved => {
if response.base { if response.base {
let creds = state.get_aws_base().await?; let creds = state.get_aws_base("default").await?;
Ok(Response::AwsBase(creds)) Ok(Response::AwsBase(creds))
} }
else { else {
let creds = state.get_aws_session().await?; let creds = state.get_aws_session("default").await?;
Ok(Response::AwsSession(creds.clone())) Ok(Response::AwsSession(creds.clone()))
} }
}, },

View File

@ -7,6 +7,7 @@ use tokio::{
sync::oneshot::{self, Sender}, sync::oneshot::{self, Sender},
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
use sqlx::types::Uuid;
use tauri::{ use tauri::{
Manager, Manager,
async_runtime as rt, async_runtime as rt,
@ -18,7 +19,11 @@ use crate::credentials::{
AwsSessionCredential, AwsSessionCredential,
}; };
use crate::{config, config::AppConfig}; use crate::{config, config::AppConfig};
use crate::credentials::{AwsBaseCredential, PersistentCredential}; use crate::credentials::{
AwsBaseCredential,
SaveCredential,
PersistentCredential
};
use crate::ipc::{self, RequestResponse}; use crate::ipc::{self, RequestResponse};
use crate::errors::*; use crate::errors::*;
use crate::shortcuts; use crate::shortcuts;
@ -137,12 +142,45 @@ impl AppState {
} }
} }
pub async fn save_creds<C>(&self, credential: C) -> Result<(), SaveCredentialsError> pub async fn save_credential(&self, cred: SaveCredential) -> Result<(), SaveCredentialsError> {
where C: PersistentCredential
{
let session = self.app_session.read().await; let session = self.app_session.read().await;
let crypto = session.try_get_crypto()?; let crypto = session.try_get_crypto()?;
credential.save(crypto, &self.pool).await cred.save(crypto, &self.pool).await
}
pub async fn delete_credential(&self, id: &Uuid) -> Result<(), SaveCredentialsError> {
sqlx::query!("DELETE FROM credentials WHERE id = ?", id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn list_credentials(&self) -> Result<Vec<SaveCredential>, GetCredentialsError> {
let session = self.app_session.read().await;
let crypto = session.try_get_crypto()?;
let creds = AwsBaseCredential::list(crypto, &self.pool).await?;
// eventual extend this vec with other credential types
Ok(creds)
}
pub async fn set_passphrase(&self, passphrase: &str) -> Result<(), SaveCredentialsError> {
let mut cur_session = self.app_session.write().await;
if let AppSession::Locked {..} = *cur_session {
return Err(SaveCredentialsError::Locked);
}
let new_session = AppSession::new(passphrase)?;
if let AppSession::Unlocked {salt: _, ref crypto} = *cur_session {
AwsBaseCredential::rekey(
crypto,
new_session.try_get_crypto().expect("AppSession::new() should always return Unlocked"),
&self.pool,
).await?;
}
new_session.save(&self.pool).await?;
*cur_session = new_session;
Ok(())
} }
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> { pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
@ -172,7 +210,6 @@ impl AppState {
c c
}; };
let mut waiting_requests = self.waiting_requests.write().await; let mut waiting_requests = self.waiting_requests.write().await;
waiting_requests.insert(*count, sender); // `count` is the request id waiting_requests.insert(*count, sender); // `count` is the request id
*count *count
@ -218,20 +255,20 @@ impl AppState {
} }
} }
pub async fn get_aws_base(&self) -> Result<AwsBaseCredential, GetCredentialsError> { pub async fn get_aws_base(&self, name: &str) -> Result<AwsBaseCredential, GetCredentialsError> {
let app_session = self.app_session.read().await; let app_session = self.app_session.read().await;
let crypto = app_session.try_get_crypto()?; let crypto = app_session.try_get_crypto()?;
let creds = AwsBaseCredential::load(crypto, &self.pool).await?; let creds = AwsBaseCredential::load(name, crypto, &self.pool).await?;
Ok(creds) Ok(creds)
} }
pub async fn get_aws_session(&self) -> Result<RwLockReadGuard<'_, AwsSessionCredential>, GetCredentialsError> { pub async fn get_aws_session(&self, name: &str) -> Result<RwLockReadGuard<'_, AwsSessionCredential>, GetCredentialsError> {
// yes, this sometimes results in double-fetching base credentials from disk // yes, this sometimes results in double-fetching base credentials from disk
// I'm done trying to be optimal // I'm done trying to be optimal
{ {
let mut aws_session = self.aws_session.write().await; let mut aws_session = self.aws_session.write().await;
if aws_session.is_none() || aws_session.as_ref().unwrap().is_expired() { if aws_session.is_none() || aws_session.as_ref().unwrap().is_expired() {
let base_creds = self.get_aws_base().await?; let base_creds = self.get_aws_base(name).await?;
*aws_session = Some(AwsSessionCredential::from_base(&base_creds).await?); *aws_session = Some(AwsSessionCredential::from_base(&base_creds).await?);
} }
} }
@ -248,7 +285,7 @@ impl AppState {
pub async fn should_auto_lock(&self) -> bool { pub async fn should_auto_lock(&self) -> bool {
let config = self.config.read().await; let config = self.config.read().await;
if !config.auto_lock || !self.is_unlocked().await { if !config.auto_lock || self.is_locked().await {
return false; return false;
} }
@ -257,9 +294,9 @@ impl AppState {
elapsed >= config.lock_after elapsed >= config.lock_after
} }
pub async fn is_unlocked(&self) -> bool { pub async fn is_locked(&self) -> bool {
let session = self.app_session.read().await; let session = self.app_session.read().await;
matches!(*session, AppSession::Unlocked{..}) matches!(*session, AppSession::Locked {..})
} }
pub async fn register_terminal_request(&self) -> Result<(), ()> { pub async fn register_terminal_request(&self) -> Result<(), ()> {
@ -279,3 +316,41 @@ impl AppState {
*req = false; *req = false;
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::Crypto;
use sqlx::types::Uuid;
fn test_state(pool: SqlitePool) -> AppState {
let salt = [0u8; 32];
let crypto = Crypto::fixed();
AppState::new(
AppConfig::default(),
AppSession::Unlocked { salt, crypto },
pool,
vec![],
false,
)
}
#[sqlx::test(fixtures("./credentials/fixtures/aws_credentials.sql"))]
fn test_delete_credential(pool: SqlitePool) {
let state = test_state(pool);
let id = Uuid::try_parse("00000000-0000-0000-0000-000000000000").unwrap();
state.delete_credential(&id).await.unwrap();
// ensure delete-cascade went through correctly
let res = AwsBaseCredential::load(
"test",
&Crypto::fixed(),
&state.pool,
).await;
assert!(matches!(res, Err(LoadCredentialsError::NoCredentials)));
}
}

View File

@ -1,6 +1,8 @@
use std::process::Command; use std::process::Command;
use std::time::Duration;
use tauri::Manager; use tauri::Manager;
use tokio::time::sleep;
use crate::app::APP; use crate::app::APP;
use crate::errors::*; use crate::errors::*;
@ -23,38 +25,39 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> {
cmd cmd
}; };
// if session is locked or empty, wait for credentials from frontend // if session is locked, wait for credentials from frontend
if !state.is_unlocked().await { if state.is_locked().await {
app.emit("launch-terminal-request", ())?;
let lease = state.acquire_visibility_lease(0).await let lease = state.acquire_visibility_lease(0).await
.map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually? .map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually?
let (tx, rx) = tokio::sync::oneshot::channel(); let (tx, rx) = tokio::sync::oneshot::channel();
app.once("credentials-event", move |e| { app.once("unlocked", move |_| {
let success = match e.payload() { let _ = tx.send(());
"\"unlocked\"" | "\"entered\"" => true,
_ => false,
};
let _ = tx.send(success);
}); });
if !rx.await.unwrap_or(false) { let timeout = Duration::from_secs(60);
state.unregister_terminal_request().await; tokio::select! {
return Ok(()); // request was canceled by user // if the frontend is unlocked within 60 seconds, release visibility lock and proceed
_ = rx => lease.release(),
// otherwise, dump this request, but return Ok so we don't get an error popup
_ = sleep(timeout) => {
state.unregister_terminal_request().await;
eprintln!("WARNING: Request to launch terminal timed out after 60 seconds.");
return Ok(());
},
} }
lease.release();
} }
// session should really be unlocked at this point, but if the frontend misbehaves // 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 // (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) // this will result in an error popup to the user (see main hotkey handler)
if use_base { if use_base {
let base_creds = state.get_aws_base().await?; let base_creds = state.get_aws_base("default").await?;
cmd.env("AWS_ACCESS_KEY_ID", &base_creds.access_key_id); cmd.env("AWS_ACCESS_KEY_ID", &base_creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", &base_creds.secret_access_key); cmd.env("AWS_SECRET_ACCESS_KEY", &base_creds.secret_access_key);
} }
else { else {
let session_creds = state.get_aws_session().await?; let session_creds = state.get_aws_session("default").await?;
cmd.env("AWS_ACCESS_KEY_ID", &session_creds.access_key_id); 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_SECRET_ACCESS_KEY", &session_creds.secret_access_key);
cmd.env("AWS_SESSION_TOKEN", &session_creds.session_token); cmd.env("AWS_SESSION_TOKEN", &session_creds.session_token);