diff --git a/src-tauri/migrations/20240617142724_credential_split.sql b/src-tauri/migrations/20240617142724_credential_split.sql index b38eb17..8859a04 100644 --- a/src-tauri/migrations/20240617142724_credential_split.sql +++ b/src-tauri/migrations/20240617142724_credential_split.sql @@ -41,13 +41,13 @@ 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, - type TEXT NOT NULL, + credential_type TEXT NOT NULL, is_default BOOLEAN NOT NULL, created_at INTEGER NOT NULL ); -- populate with basic data from existing AWS credential -INSERT INTO credentials (id, name, type, is_default, created_at) +INSERT INTO credentials (id, name, credential_type, is_default, created_at) SELECT id, 'default', 'aws', 1, created_at FROM aws_tmp; -- new AWS-specific table diff --git a/src-tauri/src/credentials/aws.rs b/src-tauri/src/credentials/aws.rs index 8303e19..c104c80 100644 --- a/src-tauri/src/credentials/aws.rs +++ b/src-tauri/src/credentials/aws.rs @@ -25,8 +25,7 @@ use crate::errors::*; #[derive(Debug, Clone, FromRow)] pub struct AwsRow { - #[allow(dead_code)] - id: Uuid, + pub id: Uuid, access_key_id: String, secret_key_enc: Vec, nonce: Vec, @@ -272,14 +271,14 @@ mod tests { use sqlx::SqlitePool; - fn test_creds() -> AwsBaseCredential { + fn creds() -> AwsBaseCredential { AwsBaseCredential::new( "AKIAIOSFODNN7EXAMPLE".into(), "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(), ) } - fn test_creds_2() -> AwsBaseCredential { + fn creds_2() -> AwsBaseCredential { AwsBaseCredential::new( "AKIAIOSFODNN7EXAMPL2".into(), "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKE2".into(), @@ -300,107 +299,41 @@ mod tests { } - #[sqlx::test] - async fn test_save(pool: SqlitePool) { - let crypt = Crypto::random(); - let mut txn = pool.begin().await.unwrap(); - test_creds().save_details(&test_uuid_random(), &crypt, &mut txn).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 - let mut txn = pool.begin().await.unwrap(); - creds.save_details(&test_uuid(), &crypt, &mut txn).await - .expect("Failed to update AWS credentials"); - - // make sure update went through - let loaded = AwsBaseCredential::load(&test_uuid(), &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 mut txn = pool.begin().await.unwrap(); - let resp = test_creds().save_details(&id, &crypt, &mut txn).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_uuid(), &crypt, &pool).await.unwrap(); - assert_eq!(test_creds(), loaded); + assert_eq!(creds(), loaded); } #[sqlx::test(fixtures("aws_credentials"))] async fn test_load_by_name(pool: SqlitePool) { let crypt = Crypto::fixed(); - let loaded = AwsBaseCredential::load_by_name("test", &crypt, &pool).await.unwrap(); - assert_eq!(test_creds(), loaded); + let loaded = AwsBaseCredential::load_by_name("test2", &crypt, &pool).await.unwrap(); + assert_eq!(creds_2(), loaded); } - #[sqlx::test] - async fn test_save_load(pool: SqlitePool) { - let crypt = Crypto::random(); - let creds = test_creds(); - let id = test_uuid_random(); - - let mut txn = pool.begin().await.unwrap(); - creds.save_details(&id, &crypt, &mut txn).await.unwrap(); - let loaded = AwsBaseCredential::load(&id, &crypt, &pool).await.unwrap(); - - assert_eq!(creds, loaded); + #[sqlx::test(fixtures("aws_credentials"))] + async fn test_load_default(pool: SqlitePool) { + let crypt = Crypto::fixed(); + let loaded = AwsBaseCredential::load_default(&crypt, &pool).await.unwrap(); + assert_eq!(creds(), loaded) } - // #[sqlx::test(fixtures("aws_credentials"))] - // async fn test_list(pool: SqlitePool) { - // let crypt = Crypto::fixed(); - // let list = 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]); + #[sqlx::test(fixtures("aws_credentials"))] + async fn test_list(pool: SqlitePool) { + let crypt = Crypto::fixed(); + let list: Vec<_> = AwsBaseCredential::list(&pool) + .await + .expect("Failed to load credentials") + .into_iter() + .map(|r| AwsBaseCredential::from_row(r, &crypt).unwrap()) + .collect(); - // 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); - // } - // } + assert_eq!(&creds(), &list[0]); + assert_eq!(&creds_2(), &list[1]); + } } diff --git a/src-tauri/src/credentials/crypto.rs b/src-tauri/src/credentials/crypto.rs new file mode 100644 index 0000000..2f3a9a3 --- /dev/null +++ b/src-tauri/src/credentials/crypto.rs @@ -0,0 +1,116 @@ +use std::fmt::{Debug, Formatter}; +use argon2::{ + Argon2, + Algorithm, + Version, + ParamsBuilder, + password_hash::rand_core::{RngCore, OsRng}, +}; +use chacha20poly1305::{ + XChaCha20Poly1305, + XNonce, + aead::{ + Aead, + AeadCore, + KeyInit, + generic_array::GenericArray, + }, +}; + +use crate::errors::*; + + +#[derive(Clone)] +pub struct Crypto { + cipher: XChaCha20Poly1305, +} + +impl Crypto { + /// Argon2 params rationale: + /// + /// m_cost is measured in KiB, so 128 * 1024 gives us 128MiB. + /// This should roughly double the memory usage of the application + /// while deriving the key. + /// + /// p_cost is irrelevant since (at present) there isn't any parallelism + /// implemented, so we leave it at 1. + /// + /// With the above m_cost, t_cost = 8 results in about 800ms to derive + /// a key on my (somewhat older) CPU. This is probably overkill, but + /// given that it should only have to happen ~once a day for most + /// usage, it should be acceptable. + #[cfg(not(debug_assertions))] + const MEM_COST: u32 = 128 * 1024; + #[cfg(not(debug_assertions))] + const TIME_COST: u32 = 8; + + /// But since this takes a million years without optimizations, + /// we turn it way down in debug builds. + #[cfg(debug_assertions)] + const MEM_COST: u32 = 48 * 1024; + #[cfg(debug_assertions)] + const TIME_COST: u32 = 1; + + + pub fn new(passphrase: &str, salt: &[u8]) -> argon2::Result { + let params = ParamsBuilder::new() + .m_cost(Self::MEM_COST) + .p_cost(1) + .t_cost(Self::TIME_COST) + .build() + .unwrap(); // only errors if the given params are invalid + + let hasher = Argon2::new( + Algorithm::Argon2id, + Version::V0x13, + params, + ); + + let mut key = [0; 32]; + hasher.hash_password_into(passphrase.as_bytes(), &salt, &mut key)?; + let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key)); + Ok(Crypto { cipher }) + } + + #[cfg(test)] + pub fn random() -> Crypto { + // salt and key are the same length, so we can just use this + let key = Crypto::salt(); + let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key)); + Crypto { cipher } + } + + #[cfg(test)] + pub fn fixed() -> Crypto { + let key = [ + 1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + ]; + + let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key)); + Crypto { cipher } + } + + pub fn salt() -> [u8; 32] { + let mut salt = [0; 32]; + OsRng.fill_bytes(&mut salt); + salt + } + + pub fn encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec), CryptoError> { + let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + let ciphertext = self.cipher.encrypt(&nonce, data)?; + Ok((nonce, ciphertext)) + } + + pub fn decrypt(&self, nonce: &XNonce, data: &[u8]) -> Result, 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/credentials/fixtures/aws_credentials.sql b/src-tauri/src/credentials/fixtures/aws_credentials.sql index 3c6f7b4..a65c571 100644 --- a/src-tauri/src/credentials/fixtures/aws_credentials.sql +++ b/src-tauri/src/credentials/fixtures/aws_credentials.sql @@ -1,7 +1,7 @@ -INSERT INTO credentials (id, name, type, is_default, created_at) +INSERT INTO credentials (id, name, credential_type, is_default, created_at) VALUES - (X'00000000000000000000000000000000', 'test', 'aws', strftime('%s'), 1), - (X'ffffffffffffffffffffffffffffffff', 'test2', 'aws', strftime('%s'), 0); + (X'00000000000000000000000000000000', 'test', 'aws', 1, strftime('%s')), + (X'ffffffffffffffffffffffffffffffff', 'test2', 'aws', 0, strftime('%s')); INSERT INTO aws_credentials (id, access_key_id, secret_key_enc, nonce) VALUES diff --git a/src-tauri/src/credentials/mod.rs b/src-tauri/src/credentials/mod.rs index 5ab8a7d..b9f0096 100644 --- a/src-tauri/src/credentials/mod.rs +++ b/src-tauri/src/credentials/mod.rs @@ -1,31 +1,7 @@ -use std::fmt::{self, Debug, Formatter}; +use std::fmt::Formatter; -use argon2::{ - Argon2, - Algorithm, - Version, - ParamsBuilder, - password_hash::rand_core::{RngCore, OsRng}, -}; -use chacha20poly1305::{ - XChaCha20Poly1305, - XNonce, - aead::{ - Aead, - AeadCore, - KeyInit, - generic_array::GenericArray, - }, -}; -use serde::{ - Serialize, - Deserialize, - Serializer, - Deserializer, -}; -use serde::de::{self, Visitor}; +use serde::{Serialize, Deserialize}; use sqlx::{ - Error as SqlxError, FromRow, Sqlite, SqlitePool, @@ -35,11 +11,19 @@ use sqlx::{ }; use crate::errors::*; -use crate::kv; mod aws; pub use aws::{AwsBaseCredential, AwsSessionCredential}; +mod record; +pub use record::CredentialRecord; + +mod session; +pub use session::AppSession; + +mod crypto; +pub use crypto::Crypto; + #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[serde(tag = "type")] @@ -49,99 +33,6 @@ pub enum Credential { } -// we need a special type for listing structs because -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub struct CredentialRecord { - #[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 - is_default: bool, - credential: Credential, -} - -impl CredentialRecord { - pub async fn save(&self, crypto: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> { - let type_name = match &self.credential { - Credential::AwsBase(_) => AwsBaseCredential::type_name(), - _ => return Err(SaveCredentialsError::NotPersistent), - }; - - // if the credential being saved is default, make sure it's the only default of its type - let mut txn = pool.begin().await?; - if self.is_default { - sqlx::query!( - "UPDATE credentials SET is_default = 0 WHERE type = ?", - type_name - ).execute(&mut *txn).await?; - } - - // save to parent credentials table - let res = sqlx::query!( - "INSERT INTO credentials (id, name, type, is_default) - VALUES (?, ?, ?, ?) - ON CONFLICT DO UPDATE SET - name = excluded.name, - type = excluded.type, - is_default = excluded.is_default", - self.id, self.name, type_name, self.is_default - ).execute(&mut *txn).await; - - // if id is unique, but name is not, we will get an error - // (if id is not unique, this becomes an upsert due to ON CONFLICT clause) - match res { - Err(SqlxError::Database(e)) if e.is_unique_violation() => Err(SaveCredentialsError::Duplicate), - Err(e) => Err(SaveCredentialsError::DbError(e)), - Ok(_) => Ok(()) - }?; - - // save credential details to child table - match &self.credential { - Credential::AwsBase(b) => b.save_details(&self.id, crypto, &mut txn).await, - _ => Err(SaveCredentialsError::NotPersistent), - }?; - - // make it real - txn.commit().await?; - Ok(()) - } - - #[allow(unused_variables)] - pub async fn list(crypto: &Crypto, pool: &SqlitePool) -> Result, LoadCredentialsError> { - todo!() - } - - #[allow(unused_variables)] - pub async fn rekey(old: &Crypto, new: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> { - todo!() - } -} - -fn serialize_uuid(u: &Uuid, s: S) -> Result { - let mut buf = Uuid::encode_buffer(); - s.serialize_str(u.as_hyphenated().encode_lower(&mut buf)) -} - -struct UuidVisitor; - -impl<'de> Visitor<'de> for UuidVisitor { - type Value = Uuid; - - fn expecting(&self, formatter: &mut Formatter) -> fmt::Result { - write!(formatter, "a hyphenated UUID") - } - - fn visit_str(self, v: &str) -> Result { - 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 { - ds.deserialize_str(UuidVisitor) -} - - pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized { type Row: Send + Unpin + for<'r> FromRow<'r, SqliteRow>; @@ -195,250 +86,10 @@ pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized { Self::from_row(row, crypto) } -} - -#[derive(Clone, Debug)] -pub enum AppSession { - Unlocked { - salt: [u8; 32], - crypto: Crypto, - }, - Locked { - salt: [u8; 32], - verify_nonce: XNonce, - verify_blob: Vec - }, - Empty, -} - -impl AppSession { - pub fn new(passphrase: &str) -> Result { - let salt = Crypto::salt(); - let crypto = Crypto::new(passphrase, &salt)?; - Ok(Self::Unlocked {salt, crypto}) - } - - pub fn unlock(&mut self, passphrase: &str) -> Result<(), UnlockError> { - let (salt, nonce, blob) = match self { - Self::Empty => return Err(UnlockError::NoCredentials), - Self::Unlocked {..} => return Err(UnlockError::NotLocked), - Self::Locked {salt, verify_nonce, verify_blob} => (salt, verify_nonce, verify_blob), - }; - - let crypto = Crypto::new(passphrase, salt) - .map_err(|e| CryptoError::Argon2(e))?; - - // if passphrase is incorrect, this will fail - let _verify = crypto.decrypt(&nonce, &blob)?; - - *self = Self::Unlocked {crypto, salt: *salt}; - Ok(()) - } - - pub async fn load(pool: &SqlitePool) -> Result { - match kv::load_bytes_multi!(pool, "salt", "verify_nonce", "verify_blob").await? { - Some((salt, nonce, blob)) => { - - Ok(Self::Locked { - salt: salt.try_into().map_err(|_| LoadCredentialsError::InvalidData)?, - // note: replace this with try_from at some point - verify_nonce: XNonce::clone_from_slice(&nonce), - verify_blob: blob, - }) - }, - None => Ok(Self::Empty), - } - } - - pub async fn save(&self, pool: &SqlitePool) -> Result<(), SaveCredentialsError> { - match self { - Self::Unlocked {salt, crypto} => { - let (nonce, blob) = crypto.encrypt(b"correct horse battery staple")?; - kv::save_bytes(pool, "salt", salt).await?; - kv::save_bytes(pool, "verify_nonce", &nonce.as_slice()).await?; - kv::save_bytes(pool, "verify_blob", &blob).await?; - }, - Self::Locked {salt, verify_nonce, verify_blob} => { - kv::save_bytes(pool, "salt", salt).await?; - kv::save_bytes(pool, "verify_nonce", &verify_nonce.as_slice()).await?; - kv::save_bytes(pool, "verify_blob", verify_blob).await?; - }, - // "saving" an empty session just means doing nothing - Self::Empty => (), - }; - - Ok(()) - } - - pub fn try_get_crypto(&self) -> Result<&Crypto, GetCredentialsError> { - match self { - Self::Empty => Err(GetCredentialsError::Empty), - Self::Locked {..} => Err(GetCredentialsError::Locked), - Self::Unlocked {crypto, ..} => Ok(crypto), - } - } - - 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, GetCredentialsError> { - let crypto = match self { - Self::Empty => return Err(GetCredentialsError::Empty), - Self::Locked {..} => return Err(GetCredentialsError::Locked), - Self::Unlocked {crypto, ..} => crypto, - }; - let res = crypto.decrypt(&nonce, data)?; - Ok(res) - } -} - - -#[derive(Clone)] -pub struct Crypto { - cipher: XChaCha20Poly1305, -} - -impl Crypto { - /// Argon2 params rationale: - /// - /// m_cost is measured in KiB, so 128 * 1024 gives us 128MiB. - /// This should roughly double the memory usage of the application - /// while deriving the key. - /// - /// p_cost is irrelevant since (at present) there isn't any parallelism - /// implemented, so we leave it at 1. - /// - /// With the above m_cost, t_cost = 8 results in about 800ms to derive - /// a key on my (somewhat older) CPU. This is probably overkill, but - /// given that it should only have to happen ~once a day for most - /// usage, it should be acceptable. - #[cfg(not(debug_assertions))] - const MEM_COST: u32 = 128 * 1024; - #[cfg(not(debug_assertions))] - const TIME_COST: u32 = 8; - - /// But since this takes a million years without optimizations, - /// we turn it way down in debug builds. - #[cfg(debug_assertions)] - const MEM_COST: u32 = 48 * 1024; - #[cfg(debug_assertions)] - const TIME_COST: u32 = 1; - - - fn new(passphrase: &str, salt: &[u8]) -> argon2::Result { - let params = ParamsBuilder::new() - .m_cost(Self::MEM_COST) - .p_cost(1) - .t_cost(Self::TIME_COST) - .build() - .unwrap(); // only errors if the given params are invalid - - let hasher = Argon2::new( - Algorithm::Argon2id, - Version::V0x13, - params, - ); - - let mut key = [0; 32]; - hasher.hash_password_into(passphrase.as_bytes(), &salt, &mut key)?; - let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key)); - Ok(Crypto { cipher }) - } - - #[cfg(test)] - pub fn random() -> Crypto { - // salt and key are the same length, so we can just use this - let key = Crypto::salt(); - let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key)); - Crypto { cipher } - } - - #[cfg(test)] - pub fn fixed() -> Crypto { - let key = [ - 1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, - 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, - ]; - - let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key)); - Crypto { cipher } - } - - fn salt() -> [u8; 32] { - let mut salt = [0; 32]; - OsRng.fill_bytes(&mut salt); - salt - } - - 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, 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 {{ [...] }}") - } -} - - -#[cfg(test)] -mod tests { - use super::*; - - - #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] - struct UuidWrapper { - #[serde(serialize_with = "serialize_uuid")] - #[serde(deserialize_with = "deserialize_uuid")] - id: Uuid, - } - - #[test] - fn test_serialize_uuid() { - let u = UuidWrapper { - id: Uuid::try_parse("693f84d2-4c1b-41e5-8483-cbe178324e04").unwrap() - }; - let computed = serde_json::to_string(&u).unwrap(); - assert_eq!( - "{\"id\":\"693f84d2-4c1b-41e5-8483-cbe178324e04\"}", - &computed, - ); - } - - #[test] - fn test_deserialize_uuid() { - let s = "{\"id\":\"045bd359-8630-4b76-9b7d-e4a86ed2222c\"}"; - let computed = serde_json::from_str(s).unwrap(); - let expected = UuidWrapper { - id: Uuid::try_parse("045bd359-8630-4b76-9b7d-e4a86ed2222c").unwrap(), - }; - assert_eq!(expected, computed); - } - - #[test] - fn test_serialize_deserialize_uuid() { - let buf = Crypto::salt(); - let expected = UuidWrapper{ - id: Uuid::from_slice(&buf[..16]).unwrap() - }; - let serialized = serde_json::to_string(&expected).unwrap(); - let computed = serde_json::from_str(&serialized).unwrap(); - assert_eq!(expected, computed) + async fn list(pool: &SqlitePool) -> Result, LoadCredentialsError> { + let q = format!("SELECT * FROM {}", Self::table_name()); + let rows: Vec = sqlx::query_as(&q).fetch_all(pool).await?; + Ok(rows) } } diff --git a/src-tauri/src/credentials/record.rs b/src-tauri/src/credentials/record.rs new file mode 100644 index 0000000..23c7e2c --- /dev/null +++ b/src-tauri/src/credentials/record.rs @@ -0,0 +1,381 @@ +use std::collections::HashMap; +use std::fmt::{self, Debug, Formatter}; +use serde::{ + Serialize, + Deserialize, + Serializer, + Deserializer, +}; +use serde::de::{self, Visitor}; +use sqlx::{ + Error as SqlxError, + FromRow, + SqlitePool, + types::Uuid, +}; +use tokio_stream::StreamExt; + +use crate::errors::*; +use super::{ + AwsBaseCredential, + aws::AwsRow, + Credential, + Crypto, + PersistentCredential, +}; + + +#[derive(Debug, Clone, FromRow)] +struct CredentialRow { + id: Uuid, + name: String, + credential_type: String, + is_default: bool, + created_at: i64, +} + + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct CredentialRecord { + #[serde(serialize_with = "serialize_uuid")] + #[serde(deserialize_with = "deserialize_uuid")] + id: Uuid, // UUID so it can be generated on the frontend + name: String, // user-facing identifier so it can be changed + is_default: bool, + credential: Credential, +} + +impl CredentialRecord { + pub async fn save(&self, crypto: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> { + let type_name = match &self.credential { + Credential::AwsBase(_) => AwsBaseCredential::type_name(), + _ => return Err(SaveCredentialsError::NotPersistent), + }; + + // if the credential being saved is default, make sure it's the only default of its type + let mut txn = pool.begin().await?; + if self.is_default { + sqlx::query!( + "UPDATE credentials SET is_default = 0 WHERE credential_type = ?", + type_name + ).execute(&mut *txn).await?; + } + + // save to parent credentials table + let res = sqlx::query!( + "INSERT INTO credentials (id, name, credential_type, is_default, created_at) + VALUES (?, ?, ?, ?, strftime('%s')) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + credential_type = excluded.credential_type, + is_default = excluded.is_default", + self.id, self.name, type_name, self.is_default + ).execute(&mut *txn).await; + + // if id is unique, but name is not, we will get an error + // (if id is not unique, this becomes an upsert due to ON CONFLICT clause) + match res { + Err(SqlxError::Database(e)) if e.is_unique_violation() => Err(SaveCredentialsError::Duplicate), + Err(e) => Err(SaveCredentialsError::DbError(e)), + Ok(_) => Ok(()) + }?; + + // save credential details to child table + match &self.credential { + Credential::AwsBase(b) => b.save_details(&self.id, crypto, &mut txn).await, + _ => Err(SaveCredentialsError::NotPersistent), + }?; + + // make it real + txn.commit().await?; + Ok(()) + } + + fn from_parts(row: CredentialRow, credential: Credential) -> Self { + CredentialRecord { + id: row.id, + name: row.name, + is_default: row.is_default, + credential, + } + } + + async fn load_details(row: CredentialRow, crypto: &Crypto, pool: &SqlitePool) -> Result { + let credential = match row.credential_type.as_str() { + "aws" => Credential::AwsBase(AwsBaseCredential::load(&row.id, crypto, pool).await?), + _ => return Err(LoadCredentialsError::InvalidData), + }; + + Ok(Self::from_parts(row, credential)) + } + + pub async fn load(id: &Uuid, crypto: &Crypto, pool: &SqlitePool) -> Result { + let row: CredentialRow = sqlx::query_as("SELECT * FROM credentials WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await? + .ok_or(LoadCredentialsError::NoCredentials)?; + + Self::load_details(row, crypto, pool).await + } + + pub async fn load_default(credential_type: &str, crypto: &Crypto, pool: &SqlitePool) -> Result { + let row: CredentialRow = sqlx::query_as( + "SELECT * FROM credentials + WHERE credential_type = ? AND is_default = 1" + ).bind(credential_type) + .fetch_optional(pool) + .await? + .ok_or(LoadCredentialsError::NoCredentials)?; + + Self::load_details(row, crypto, pool).await + } + + pub async fn list(crypto: &Crypto, pool: &SqlitePool) -> Result, LoadCredentialsError> { + let mut parent_rows = sqlx::query_as::<_, CredentialRow>( + "SELECT * FROM credentials" + ).fetch(pool); + + let mut parent_map = HashMap::new(); + while let Some(row) = parent_rows.try_next().await? { + parent_map.insert(row.id, row); + } + + let mut records = Vec::with_capacity(parent_map.len()); + + for row in AwsBaseCredential::list(&pool).await? { + let parent = parent_map.remove(&row.id) + .ok_or(LoadCredentialsError::InvalidData)?; + let credential = Credential::AwsBase(AwsBaseCredential::from_row(row, crypto)?); + records.push(Self::from_parts(parent, credential)); + } + + Ok(records) + } + + #[allow(unused_variables)] + pub async fn rekey(old: &Crypto, new: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> { + todo!() + } +} + + +fn serialize_uuid(u: &Uuid, s: S) -> Result { + let mut buf = Uuid::encode_buffer(); + s.serialize_str(u.as_hyphenated().encode_lower(&mut buf)) +} + +struct UuidVisitor; + +impl<'de> Visitor<'de> for UuidVisitor { + type Value = Uuid; + + fn expecting(&self, formatter: &mut Formatter) -> fmt::Result { + write!(formatter, "a hyphenated UUID") + } + + fn visit_str(self, v: &str) -> Result { + 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 { + ds.deserialize_str(UuidVisitor) +} + + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::types::uuid::uuid; + + + fn aws_record() -> CredentialRecord { + let id = uuid!("00000000-0000-0000-0000-000000000000"); + let aws = AwsBaseCredential::new( + "AKIAIOSFODNN7EXAMPLE".into(), + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(), + ); + + CredentialRecord { + id, + name: "test".into(), + is_default: true, + credential: Credential::AwsBase(aws), + } + } + + fn aws_record_2() -> CredentialRecord { + let id = uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff"); + let aws = AwsBaseCredential::new( + "AKIAIOSFODNN7EXAMPL2".into(), + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKE2".into(), + ); + + CredentialRecord { + id, + name: "test2".into(), + is_default: false, + credential: Credential::AwsBase(aws), + } + } + + fn random_uuid() -> Uuid { + let bytes = Crypto::salt(); + Uuid::from_slice(&bytes[..16]).unwrap() + } + + + #[sqlx::test(fixtures("aws_credentials"))] + async fn test_load_aws(pool: SqlitePool) { + let crypt = Crypto::fixed(); + let id = uuid!("00000000-0000-0000-0000-000000000000"); + let loaded = CredentialRecord::load(&id, &crypt, &pool).await + .expect("Failed to load record"); + + assert_eq!(aws_record(), loaded); + } + + + #[sqlx::test(fixtures("aws_credentials"))] + async fn test_load_aws_default(pool: SqlitePool) { + let crypt = Crypto::fixed(); + let loaded = CredentialRecord::load_default("aws", &crypt, &pool).await + .expect("Failed to load record"); + + assert_eq!(aws_record(), loaded); + } + + + #[sqlx::test] + async fn test_save_aws(pool: SqlitePool) { + let crypt = Crypto::random(); + let mut record = aws_record(); + record.id = random_uuid(); + + aws_record().save(&crypt, &pool).await + .expect("Failed to save record"); + } + + + #[sqlx::test] + async fn test_save_load(pool: SqlitePool) { + let crypt = Crypto::random(); + let mut record = aws_record(); + record.id = random_uuid(); + + record.save(&crypt, &pool).await + .expect("Failed to save record"); + let loaded = CredentialRecord::load(&record.id, &crypt, &pool).await + .expect("Failed to load record"); + + assert_eq!(record, loaded); + } + + + async fn test_overwrite_aws(pool: SqlitePool) { + let crypt = Crypto::fixed(); + + let original = aws_record(); + original.save(&crypt, &pool).await + .expect("Failed to save first record"); + + let mut updated = aws_record_2(); + updated.id = original.id; + updated.save(&crypt, &pool).await + .expect("Failed to overwrite first record with second record"); + + // make sure update went through + let loaded = CredentialRecord::load(&updated.id, &crypt, &pool).await.unwrap(); + assert_eq!(updated, loaded); + } + + + #[sqlx::test(fixtures("aws_credentials"))] + async fn test_duplicate_name(pool: SqlitePool) { + let crypt = Crypto::random(); + + let mut record = aws_record(); + record.id = random_uuid(); + let resp = record.save(&crypt, &pool).await; + + if !matches!(resp, Err(SaveCredentialsError::Duplicate)) { + panic!("Attempt to create duplicate entry returned {resp:?}") + } + } + + + #[sqlx::test(fixtures("aws_credentials"))] + async fn test_change_default(pool: SqlitePool) { + let crypt = Crypto::fixed(); + let id = uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff"); + + // confirm that record as it currently exists in the database is not default + let mut record = CredentialRecord::load(&id, &crypt, &pool).await + .expect("Failed to load record"); + assert!(!record.is_default); + + record.is_default = true; + record.save(&crypt, &pool).await + .expect("Failed to save record"); + + let loaded = CredentialRecord::load(&id, &crypt, &pool).await + .expect("Failed to re-load record"); + assert!(loaded.is_default); + + let other_id = uuid!("00000000-0000-0000-0000-000000000000"); + let other_loaded = CredentialRecord::load(&other_id, &crypt, &pool).await + .expect("Failed to load other credential"); + assert!(!other_loaded.is_default); + } +} + + +#[cfg(test)] +mod uuid_tests { + use super::*; + use sqlx::types::uuid::uuid; + + + #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] + struct UuidWrapper { + #[serde(serialize_with = "serialize_uuid")] + #[serde(deserialize_with = "deserialize_uuid")] + id: Uuid, + } + + + #[test] + fn test_serialize_uuid() { + let u = UuidWrapper { + id: uuid!("693f84d2-4c1b-41e5-8483-cbe178324e04") + }; + let computed = serde_json::to_string(&u).unwrap(); + assert_eq!( + "{\"id\":\"693f84d2-4c1b-41e5-8483-cbe178324e04\"}", + &computed, + ); + } + + #[test] + fn test_deserialize_uuid() { + let s = "{\"id\":\"045bd359-8630-4b76-9b7d-e4a86ed2222c\"}"; + let computed = serde_json::from_str(s).unwrap(); + let expected = UuidWrapper { + id: uuid!("045bd359-8630-4b76-9b7d-e4a86ed2222c"), + }; + assert_eq!(expected, computed); + } + + #[test] + fn test_serialize_deserialize_uuid() { + let buf = Crypto::salt(); + let expected = UuidWrapper{ + id: Uuid::from_slice(&buf[..16]).unwrap() + }; + let serialized = serde_json::to_string(&expected).unwrap(); + let computed = serde_json::from_str(&serialized).unwrap(); + assert_eq!(expected, computed) + } +} diff --git a/src-tauri/src/credentials/session.rs b/src-tauri/src/credentials/session.rs new file mode 100644 index 0000000..3afc8de --- /dev/null +++ b/src-tauri/src/credentials/session.rs @@ -0,0 +1,109 @@ +use chacha20poly1305::XNonce; +use sqlx::SqlitePool; + +use crate::errors::*; +use crate::kv; +use super::Crypto; + + +#[derive(Clone, Debug)] +pub enum AppSession { + Unlocked { + salt: [u8; 32], + crypto: Crypto, + }, + Locked { + salt: [u8; 32], + verify_nonce: XNonce, + verify_blob: Vec + }, + Empty, +} + +impl AppSession { + pub fn new(passphrase: &str) -> Result { + let salt = Crypto::salt(); + let crypto = Crypto::new(passphrase, &salt)?; + Ok(Self::Unlocked {salt, crypto}) + } + + pub fn unlock(&mut self, passphrase: &str) -> Result<(), UnlockError> { + let (salt, nonce, blob) = match self { + Self::Empty => return Err(UnlockError::NoCredentials), + Self::Unlocked {..} => return Err(UnlockError::NotLocked), + Self::Locked {salt, verify_nonce, verify_blob} => (salt, verify_nonce, verify_blob), + }; + + let crypto = Crypto::new(passphrase, salt) + .map_err(|e| CryptoError::Argon2(e))?; + + // if passphrase is incorrect, this will fail + let _verify = crypto.decrypt(&nonce, &blob)?; + + *self = Self::Unlocked {crypto, salt: *salt}; + Ok(()) + } + + pub async fn load(pool: &SqlitePool) -> Result { + match kv::load_bytes_multi!(pool, "salt", "verify_nonce", "verify_blob").await? { + Some((salt, nonce, blob)) => { + + Ok(Self::Locked { + salt: salt.try_into().map_err(|_| LoadCredentialsError::InvalidData)?, + // note: replace this with try_from at some point + verify_nonce: XNonce::clone_from_slice(&nonce), + verify_blob: blob, + }) + }, + None => Ok(Self::Empty), + } + } + + pub async fn save(&self, pool: &SqlitePool) -> Result<(), SaveCredentialsError> { + match self { + Self::Unlocked {salt, crypto} => { + let (nonce, blob) = crypto.encrypt(b"correct horse battery staple")?; + kv::save_bytes(pool, "salt", salt).await?; + kv::save_bytes(pool, "verify_nonce", &nonce.as_slice()).await?; + kv::save_bytes(pool, "verify_blob", &blob).await?; + }, + Self::Locked {salt, verify_nonce, verify_blob} => { + kv::save_bytes(pool, "salt", salt).await?; + kv::save_bytes(pool, "verify_nonce", &verify_nonce.as_slice()).await?; + kv::save_bytes(pool, "verify_blob", verify_blob).await?; + }, + // "saving" an empty session just means doing nothing + Self::Empty => (), + }; + + Ok(()) + } + + pub fn try_get_crypto(&self) -> Result<&Crypto, GetCredentialsError> { + match self { + Self::Empty => Err(GetCredentialsError::Empty), + Self::Locked {..} => Err(GetCredentialsError::Locked), + Self::Unlocked {crypto, ..} => Ok(crypto), + } + } + + 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, GetCredentialsError> { + let crypto = match self { + Self::Empty => return Err(GetCredentialsError::Empty), + Self::Locked {..} => return Err(GetCredentialsError::Locked), + Self::Unlocked {crypto, ..} => crypto, + }; + let res = crypto.decrypt(&nonce, data)?; + Ok(res) + } +} \ No newline at end of file diff --git a/src/ui/PassphraseInput.svelte b/src/ui/PassphraseInput.svelte new file mode 100644 index 0000000..c6b0e8d --- /dev/null +++ b/src/ui/PassphraseInput.svelte @@ -0,0 +1,44 @@ + + + + + + +
+ value = e.target.value} + /> + + +
diff --git a/src/ui/icons/eye-slash.svelte b/src/ui/icons/eye-slash.svelte new file mode 100644 index 0000000..3d88881 --- /dev/null +++ b/src/ui/icons/eye-slash.svelte @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/ui/icons/eye.svelte b/src/ui/icons/eye.svelte new file mode 100644 index 0000000..27346ba --- /dev/null +++ b/src/ui/icons/eye.svelte @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/ui/icons/pencil.svelte b/src/ui/icons/pencil.svelte new file mode 100644 index 0000000..b97450f --- /dev/null +++ b/src/ui/icons/pencil.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/src/ui/icons/plus-circle-mini.svelte b/src/ui/icons/plus-circle-mini.svelte new file mode 100644 index 0000000..5288cd1 --- /dev/null +++ b/src/ui/icons/plus-circle-mini.svelte @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/ui/icons/trash.svelte b/src/ui/icons/trash.svelte new file mode 100644 index 0000000..3cc85da --- /dev/null +++ b/src/ui/icons/trash.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/src/views/ChangePassphrase.svelte b/src/views/ChangePassphrase.svelte new file mode 100644 index 0000000..120d978 --- /dev/null +++ b/src/views/ChangePassphrase.svelte @@ -0,0 +1,14 @@ + + + +
+

+ Change passphrase +

+ + navigate('Home')}/> +
diff --git a/src/views/CreatePassphrase.svelte b/src/views/CreatePassphrase.svelte new file mode 100644 index 0000000..bad48ed --- /dev/null +++ b/src/views/CreatePassphrase.svelte @@ -0,0 +1,21 @@ + + + +
+
+

Welcome to Creddy!

+ +
+

Create a passphrase to get started.

+ +

Please note that if you forget your passphrase, there is no way to recover + your stored credentials. You will have to start over with a new passphrase.

+
+ +
+ +
+
+
diff --git a/src/views/EnterAwsCredential.svelte b/src/views/EnterAwsCredential.svelte new file mode 100644 index 0000000..720e739 --- /dev/null +++ b/src/views/EnterAwsCredential.svelte @@ -0,0 +1,93 @@ + + + + +
+

Enter your credentials

+ + {#if errorMsg} + {errorMsg} + {/if} + + + + + + + + + + +
diff --git a/src/views/ManageCredentials.svelte b/src/views/ManageCredentials.svelte new file mode 100644 index 0000000..8c1b247 --- /dev/null +++ b/src/views/ManageCredentials.svelte @@ -0,0 +1,62 @@ + + + + + +
+
+

AWS Access Keys

+
+ + {#if records.length > 0} +
+ {#each records as record (record.id)} + + {/each} +
+ + {:else} +
+
You have no saved AWS credentials.
+ +
+ {/if} + +
diff --git a/src/views/approve/CollectResponse.svelte b/src/views/approve/CollectResponse.svelte new file mode 100644 index 0000000..485a602 --- /dev/null +++ b/src/views/approve/CollectResponse.svelte @@ -0,0 +1,91 @@ + + + +{#if $appState.currentRequest?.base} +
+
+ + + WARNING: This application is requesting your base AWS credentials. + These credentials are less secure than session credentials, since they don't expire automatically. + +
+
+{/if} + +
+

{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.

+ +
+
Path:
+ {@html client.exe ? breakPath(client.exe) : 'Unknown'} +
PID:
+ {client.pid} +
+
+ +
+ + {#if !$appState.currentRequest?.base} +

+ Approve with session credentials +

+ setResponse('Approved', false)} hotkey="Enter" shift={true}> + + + {/if} + +

+ + {#if $appState.currentRequest?.base} + Approve + {:else} + Approve with base credentials + {/if} + +

+ setResponse('Approved', true)} hotkey="Enter" shift={true} ctrl={true}> + + + +

+ Deny +

+ setResponse('Denied', false)} hotkey="Escape"> + + +
diff --git a/src/views/approve/ShowResponse.svelte b/src/views/approve/ShowResponse.svelte new file mode 100644 index 0000000..a4ce3cd --- /dev/null +++ b/src/views/approve/ShowResponse.svelte @@ -0,0 +1,29 @@ + + + +
+ {#if $appState.currentRequest.response.approval === 'Approved'} + + + + {:else} + + + + {/if} + +
+ {$appState.currentRequest.response.approval}! +
+
diff --git a/src/views/credentials/AwsCredential.svelte b/src/views/credentials/AwsCredential.svelte new file mode 100644 index 0000000..8771a92 --- /dev/null +++ b/src/views/credentials/AwsCredential.svelte @@ -0,0 +1,157 @@ + + + +
+
+

{record.name}

+ + {#if record.is_default} + Default + {/if} + +
+ + +
+
+ + + {#if showDetails} + {#if error} + {error} + {/if} + +
+
+ {#if record.isNew} + Name + + {/if} + + Key ID + + + Secret key +
+ +
+
+ +
+ + {#if isModified} + + {/if} +
+
+ {/if} + + + + +
diff --git a/src/views/passphrase/EnterPassphrase.svelte b/src/views/passphrase/EnterPassphrase.svelte new file mode 100644 index 0000000..59a07bb --- /dev/null +++ b/src/views/passphrase/EnterPassphrase.svelte @@ -0,0 +1,84 @@ + + + +
+ {#if error} + {error} + {/if} + + + + + + + + {#if cancellable} + + + + {/if} +