almost finish refactoring PersistentCredential trait
This commit is contained in:
		| @@ -41,13 +41,13 @@ CREATE TABLE credentials ( | |||||||
|     -- id is a UUID so we can generate it on the frontend |     -- id is a UUID so we can generate it on the frontend | ||||||
|     id BLOB UNIQUE NOT NULL, |     id BLOB UNIQUE NOT NULL, | ||||||
|     name TEXT UNIQUE NOT NULL, |     name TEXT UNIQUE NOT NULL, | ||||||
|     type TEXT NOT NULL, |     credential_type TEXT NOT NULL, | ||||||
|     is_default BOOLEAN NOT NULL, |     is_default BOOLEAN NOT NULL, | ||||||
|     created_at INTEGER NOT NULL |     created_at INTEGER NOT NULL | ||||||
| ); | ); | ||||||
|  |  | ||||||
| -- populate with basic data from existing AWS credential | -- 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; | SELECT id, 'default', 'aws', 1, created_at FROM aws_tmp; | ||||||
|  |  | ||||||
| -- new AWS-specific table | -- new AWS-specific table | ||||||
|   | |||||||
| @@ -25,8 +25,7 @@ use crate::errors::*; | |||||||
|  |  | ||||||
| #[derive(Debug, Clone, FromRow)] | #[derive(Debug, Clone, FromRow)] | ||||||
| pub struct AwsRow { | pub struct AwsRow { | ||||||
|     #[allow(dead_code)] |     pub id: Uuid, | ||||||
|     id: Uuid, |  | ||||||
|     access_key_id: String, |     access_key_id: String, | ||||||
|     secret_key_enc: Vec<u8>, |     secret_key_enc: Vec<u8>, | ||||||
|     nonce: Vec<u8>, |     nonce: Vec<u8>, | ||||||
| @@ -272,14 +271,14 @@ mod tests { | |||||||
|     use sqlx::SqlitePool; |     use sqlx::SqlitePool; | ||||||
|  |  | ||||||
|  |  | ||||||
|     fn test_creds() -> AwsBaseCredential { |     fn creds() -> AwsBaseCredential { | ||||||
|         AwsBaseCredential::new( |         AwsBaseCredential::new( | ||||||
|             "AKIAIOSFODNN7EXAMPLE".into(), |             "AKIAIOSFODNN7EXAMPLE".into(), | ||||||
|             "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(), |             "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(), | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fn test_creds_2() -> AwsBaseCredential { |     fn creds_2() -> AwsBaseCredential { | ||||||
|         AwsBaseCredential::new( |         AwsBaseCredential::new( | ||||||
|             "AKIAIOSFODNN7EXAMPL2".into(), |             "AKIAIOSFODNN7EXAMPL2".into(), | ||||||
|             "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKE2".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"))] |     #[sqlx::test(fixtures("aws_credentials"))] | ||||||
|     async fn test_load(pool: SqlitePool) { |     async fn test_load(pool: SqlitePool) { | ||||||
|         let crypt = Crypto::fixed(); |         let crypt = Crypto::fixed(); | ||||||
|         let loaded = AwsBaseCredential::load(&test_uuid(), &crypt, &pool).await.unwrap(); |         let loaded = AwsBaseCredential::load(&test_uuid(), &crypt, &pool).await.unwrap(); | ||||||
|         assert_eq!(test_creds(), loaded); |         assert_eq!(creds(), loaded); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|     #[sqlx::test(fixtures("aws_credentials"))] |     #[sqlx::test(fixtures("aws_credentials"))] | ||||||
|     async fn test_load_by_name(pool: SqlitePool) { |     async fn test_load_by_name(pool: SqlitePool) { | ||||||
|         let crypt = Crypto::fixed(); |         let crypt = Crypto::fixed(); | ||||||
|         let loaded = AwsBaseCredential::load_by_name("test", &crypt, &pool).await.unwrap(); |         let loaded = AwsBaseCredential::load_by_name("test2", &crypt, &pool).await.unwrap(); | ||||||
|         assert_eq!(test_creds(), loaded); |         assert_eq!(creds_2(), loaded); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|     #[sqlx::test] |     #[sqlx::test(fixtures("aws_credentials"))] | ||||||
|     async fn test_save_load(pool: SqlitePool) { |     async fn test_load_default(pool: SqlitePool) { | ||||||
|         let crypt = Crypto::random(); |         let crypt = Crypto::fixed(); | ||||||
|         let creds = test_creds(); |         let loaded = AwsBaseCredential::load_default(&crypt, &pool).await.unwrap(); | ||||||
|         let id = test_uuid_random(); |         assert_eq!(creds(), loaded) | ||||||
|  |  | ||||||
|         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_list(pool: SqlitePool) { |  | ||||||
|     //     let crypt = Crypto::fixed(); |  | ||||||
|     //     let list = AwsBaseCredential::list(&crypt, &pool).await |  | ||||||
|     //         .expect("Failed to list AWS credentials"); |  | ||||||
|  |  | ||||||
|     //     let first = SaveCredential { |     #[sqlx::test(fixtures("aws_credentials"))] | ||||||
|     //         id: test_uuid(), |     async fn test_list(pool: SqlitePool) { | ||||||
|     //         name: "test".into(), |         let crypt = Crypto::fixed(); | ||||||
|     //         credential: Credential::AwsBase(test_creds()), |         let list: Vec<_> = AwsBaseCredential::list(&pool) | ||||||
|     //     }; |             .await | ||||||
|     //     assert_eq!(&first, &list[0]); |             .expect("Failed to load credentials") | ||||||
|  |             .into_iter() | ||||||
|  |             .map(|r| AwsBaseCredential::from_row(r, &crypt).unwrap()) | ||||||
|  |             .collect(); | ||||||
|  |  | ||||||
|     //     let second = SaveCredential { |         assert_eq!(&creds(), &list[0]); | ||||||
|     //         id: test_uuid_2(), |         assert_eq!(&creds_2(), &list[1]); | ||||||
|     //         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); |  | ||||||
|     //     } |  | ||||||
|     // } |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										116
									
								
								src-tauri/src/credentials/crypto.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								src-tauri/src/credentials/crypto.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<Crypto> { | ||||||
|  |         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<u8>), 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<Vec<u8>, 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 {{ [...] }}") | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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 | VALUES | ||||||
|     (X'00000000000000000000000000000000', 'test', 'aws', strftime('%s'), 1), |     (X'00000000000000000000000000000000', 'test', 'aws', 1, strftime('%s')), | ||||||
|     (X'ffffffffffffffffffffffffffffffff', 'test2', 'aws', strftime('%s'), 0); |     (X'ffffffffffffffffffffffffffffffff', 'test2', 'aws', 0, strftime('%s')); | ||||||
|  |  | ||||||
| INSERT INTO aws_credentials (id, access_key_id, secret_key_enc, nonce) | INSERT INTO aws_credentials (id, access_key_id, secret_key_enc, nonce) | ||||||
| VALUES  | VALUES  | ||||||
|   | |||||||
| @@ -1,31 +1,7 @@ | |||||||
| use std::fmt::{self, Debug, Formatter}; | use std::fmt::Formatter; | ||||||
|  |  | ||||||
| use argon2::{ | use serde::{Serialize, Deserialize}; | ||||||
|     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 sqlx::{ | use sqlx::{ | ||||||
|     Error as SqlxError, |  | ||||||
|     FromRow, |     FromRow, | ||||||
|     Sqlite, |     Sqlite, | ||||||
|     SqlitePool, |     SqlitePool, | ||||||
| @@ -35,11 +11,19 @@ use sqlx::{ | |||||||
| }; | }; | ||||||
|  |  | ||||||
| use crate::errors::*; | use crate::errors::*; | ||||||
| use crate::kv; |  | ||||||
|  |  | ||||||
| mod aws; | mod aws; | ||||||
| pub use aws::{AwsBaseCredential, AwsSessionCredential}; | 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)] | #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] | ||||||
| #[serde(tag = "type")] | #[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<Vec<Self>, LoadCredentialsError> { |  | ||||||
|         todo!() |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     #[allow(unused_variables)] |  | ||||||
|     pub async fn rekey(old: &Crypto, new: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> { |  | ||||||
|         todo!() |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| fn serialize_uuid<S: Serializer>(u: &Uuid, s: S) -> Result<S::Ok, S::Error> { |  | ||||||
|     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<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 { | pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized { | ||||||
|     type Row: Send + Unpin + for<'r> FromRow<'r, SqliteRow>; |     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) |         Self::from_row(row, crypto) | ||||||
|     } |     } | ||||||
| } |  | ||||||
|  |  | ||||||
|  |     async fn list(pool: &SqlitePool) -> Result<Vec<Self::Row>, LoadCredentialsError> { | ||||||
| #[derive(Clone, Debug)] |         let q = format!("SELECT * FROM {}", Self::table_name()); | ||||||
| pub enum AppSession { |         let rows: Vec<Self::Row> = sqlx::query_as(&q).fetch_all(pool).await?; | ||||||
|     Unlocked { |         Ok(rows) | ||||||
|         salt: [u8; 32], |  | ||||||
|         crypto: Crypto, |  | ||||||
|     }, |  | ||||||
|     Locked { |  | ||||||
|         salt: [u8; 32], |  | ||||||
|         verify_nonce: XNonce, |  | ||||||
|         verify_blob: Vec<u8> |  | ||||||
|     }, |  | ||||||
|     Empty, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl AppSession { |  | ||||||
|     pub fn new(passphrase: &str) -> Result<Self, CryptoError> { |  | ||||||
|         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<Self, LoadCredentialsError> { |  | ||||||
|         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<u8>), 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<Vec<u8>, 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<Crypto> { |  | ||||||
|         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<u8>), 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<Vec<u8>, 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) |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										381
									
								
								src-tauri/src/credentials/record.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										381
									
								
								src-tauri/src/credentials/record.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<Self, LoadCredentialsError> { | ||||||
|  |         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<Self, LoadCredentialsError> { | ||||||
|  |         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<Self, LoadCredentialsError> { | ||||||
|  |         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<Vec<Self>, 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<S: Serializer>(u: &Uuid, s: S) -> Result<S::Ok, S::Error> { | ||||||
|  |     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<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) | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | #[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) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										109
									
								
								src-tauri/src/credentials/session.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								src-tauri/src/credentials/session.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<u8> | ||||||
|  |     }, | ||||||
|  |     Empty, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl AppSession { | ||||||
|  |     pub fn new(passphrase: &str) -> Result<Self, CryptoError> { | ||||||
|  |         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<Self, LoadCredentialsError> { | ||||||
|  |         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<u8>), 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<Vec<u8>, 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) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										44
									
								
								src/ui/PassphraseInput.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/ui/PassphraseInput.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | |||||||
|  | <script> | ||||||
|  |     import Icon from './Icon.svelte'; | ||||||
|  |  | ||||||
|  |     export let value = ''; | ||||||
|  |     export let placeholder = ''; | ||||||
|  |     export let autofocus = false; | ||||||
|  |  | ||||||
|  |     let show = false; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  |  | ||||||
|  | <style> | ||||||
|  |     button { | ||||||
|  |         border: 1px solid oklch(var(--bc) / 0.2); | ||||||
|  |         border-left: none; | ||||||
|  |     } | ||||||
|  | </style> | ||||||
|  |  | ||||||
|  |  | ||||||
|  | <div class="join w-full"> | ||||||
|  |     <input | ||||||
|  |         type={show ? 'text' : 'password'} | ||||||
|  |         {value} {placeholder} {autofocus} | ||||||
|  |         on:input on:change on:focus on:blur | ||||||
|  |         class="input input-bordered flex-grow join-item placeholder:text-gray-500" | ||||||
|  |         on:input={e => value = e.target.value} | ||||||
|  |     /> | ||||||
|  |  | ||||||
|  |     <button | ||||||
|  |         type="button" | ||||||
|  |         class="btn btn-ghost join-item swap swap-rotate" | ||||||
|  |         class:swap-active={show} | ||||||
|  |         on:click={() => show = !show} | ||||||
|  |     > | ||||||
|  |         <Icon | ||||||
|  |             name="eye" | ||||||
|  |             class="w-5 h-5 swap-off" | ||||||
|  |         /> | ||||||
|  |         <Icon | ||||||
|  |             name="eye-slash" | ||||||
|  |             class="w-5 h-5 swap-on" | ||||||
|  |         /> | ||||||
|  |     </button> | ||||||
|  | </div> | ||||||
							
								
								
									
										9
									
								
								src/ui/icons/eye-slash.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/ui/icons/eye-slash.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | <script> | ||||||
|  |     let classes = ""; | ||||||
|  |     export {classes as class}; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class={classes}> | ||||||
|  |   <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" /> | ||||||
|  | </svg> | ||||||
|  |  | ||||||
							
								
								
									
										9
									
								
								src/ui/icons/eye.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/ui/icons/eye.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | <script> | ||||||
|  |     let classes = ""; | ||||||
|  |     export {classes as class}; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class={classes}> | ||||||
|  |   <path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" /> | ||||||
|  |   <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> | ||||||
|  | </svg> | ||||||
							
								
								
									
										8
									
								
								src/ui/icons/pencil.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/ui/icons/pencil.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | <script> | ||||||
|  |     let classes = ''; | ||||||
|  |     export {classes as class}; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class={classes}> | ||||||
|  |   <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" /> | ||||||
|  | </svg> | ||||||
							
								
								
									
										9
									
								
								src/ui/icons/plus-circle-mini.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/ui/icons/plus-circle-mini.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | <script> | ||||||
|  |   let classes = ""; | ||||||
|  |   export {classes as class}; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class={classes}> | ||||||
|  |   <path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 0 0-1.5 0v2.5h-2.5a.75.75 0 0 0 0 1.5h2.5v2.5a.75.75 0 0 0 1.5 0v-2.5h2.5a.75.75 0 0 0 0-1.5h-2.5v-2.5Z" clip-rule="evenodd" /> | ||||||
|  | </svg> | ||||||
|  |  | ||||||
							
								
								
									
										8
									
								
								src/ui/icons/trash.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/ui/icons/trash.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | <script> | ||||||
|  |     let classes = ''; | ||||||
|  |     export {classes as class}; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class={classes}> | ||||||
|  |   <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /> | ||||||
|  | </svg> | ||||||
							
								
								
									
										14
									
								
								src/views/ChangePassphrase.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/views/ChangePassphrase.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | <script> | ||||||
|  |     import { navigate } from '../lib/routing.js'; | ||||||
|  |  | ||||||
|  |     import EnterPassphrase from './passphrase/EnterPassphrase.svelte'; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  |  | ||||||
|  | <div class="flex flex-col h-screen max-w-sm m-auto gap-y-8 justify-center"> | ||||||
|  |         <h1 class="text-2xl font-bold text-center"> | ||||||
|  |             Change passphrase | ||||||
|  |         </h1> | ||||||
|  |  | ||||||
|  |         <EnterPassphrase cancellable={true} on:save={() => navigate('Home')}/> | ||||||
|  | </div> | ||||||
							
								
								
									
										21
									
								
								src/views/CreatePassphrase.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/views/CreatePassphrase.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | <script> | ||||||
|  |     import EnterPassphrase from './passphrase/EnterPassphrase.svelte'; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  |  | ||||||
|  | <div class="flex flex-col h-screen max-w-lg m-auto justify-center"> | ||||||
|  |     <div class="space-y-8"> | ||||||
|  |         <h1 class="text-2xl font-bold text-center">Welcome to Creddy!</h1> | ||||||
|  |  | ||||||
|  |         <div class="space-y-4"> | ||||||
|  |             <p> Create a passphrase to get started.</p> | ||||||
|  |  | ||||||
|  |             <p>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.</p> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <div class="max-w-sm mx-auto"> | ||||||
|  |             <EnterPassphrase /> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  | </div> | ||||||
							
								
								
									
										93
									
								
								src/views/EnterAwsCredential.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								src/views/EnterAwsCredential.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,93 @@ | |||||||
|  | <script> | ||||||
|  |     import { onMount } from 'svelte'; | ||||||
|  |     import { invoke } from '@tauri-apps/api/core'; | ||||||
|  |     import { emit } from '@tauri-apps/api/event'; | ||||||
|  |     import { getRootCause } from '../lib/errors.js'; | ||||||
|  |  | ||||||
|  |     import { appState } from '../lib/state.js'; | ||||||
|  |     import { navigate } from '../lib/routing.js'; | ||||||
|  |     import Link from '../ui/Link.svelte'; | ||||||
|  |     import ErrorAlert from '../ui/ErrorAlert.svelte'; | ||||||
|  |     import Spinner from '../ui/Spinner.svelte'; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     let errorMsg = null; | ||||||
|  |     let alert; | ||||||
|  |     let AccessKeyId, SecretAccessKey, passphrase, confirmPassphrase | ||||||
|  |  | ||||||
|  |     function confirm() { | ||||||
|  |         if (passphrase !== confirmPassphrase) { | ||||||
|  |             errorMsg = 'Passphrases do not match.' | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let saving = false; | ||||||
|  |     async function save() { | ||||||
|  |         if (passphrase !== confirmPassphrase) { | ||||||
|  |             alert.shake(); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let credentials = {AccessKeyId, SecretAccessKey}; | ||||||
|  |         try { | ||||||
|  |             saving = true; | ||||||
|  |             await invoke('save_credentials', {credentials, passphrase}); | ||||||
|  |             emit('credentials-event', 'entered'); | ||||||
|  |             if ($appState.currentRequest) { | ||||||
|  |                 navigate('Approve'); | ||||||
|  |             } | ||||||
|  |             else { | ||||||
|  |                 navigate('Home'); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         catch (e) { | ||||||
|  |             const root = getRootCause(e); | ||||||
|  |             if (e.code === 'GetSession' && root.code) { | ||||||
|  |                 errorMsg = `Error response from AWS (${root.code}): ${root.msg}`; | ||||||
|  |             } | ||||||
|  |             else { | ||||||
|  |                 // some of the built-in Tauri errors are plain strings, | ||||||
|  |                 // so fall back to e if e.msg doesn't exist | ||||||
|  |                 errorMsg = e.msg || e; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // if the alert already existed, shake it | ||||||
|  |             if (alert) { | ||||||
|  |                 alert.shake(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             saving = false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function cancel() { | ||||||
|  |         emit('credentials-event', 'enter-canceled'); | ||||||
|  |         navigate('Home'); | ||||||
|  |     } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | <form action="#" on:submit|preventDefault="{save}" class="form-control space-y-4 max-w-sm m-auto p-4 h-screen justify-center"> | ||||||
|  |     <h2 class="text-2xl font-bold text-center">Enter your credentials</h2> | ||||||
|  |  | ||||||
|  |     {#if errorMsg} | ||||||
|  |         <ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert> | ||||||
|  |     {/if} | ||||||
|  |  | ||||||
|  |     <input type="text" placeholder="AWS Access Key ID" bind:value="{AccessKeyId}" class="input input-bordered" /> | ||||||
|  |     <input type="password" placeholder="AWS Secret Access Key" bind:value="{SecretAccessKey}" class="input input-bordered" /> | ||||||
|  |     <input type="password" placeholder="Passphrase" bind:value="{passphrase}" class="input input-bordered" /> | ||||||
|  |     <input type="password" placeholder="Re-enter passphrase" bind:value={confirmPassphrase} class="input input-bordered" on:change={confirm} /> | ||||||
|  |  | ||||||
|  |     <button type="submit" class="btn btn-primary"> | ||||||
|  |         {#if saving } | ||||||
|  |             <Spinner class="w-5 h-5" thickness="12"/> | ||||||
|  |         {:else} | ||||||
|  |             Submit | ||||||
|  |         {/if} | ||||||
|  |     </button> | ||||||
|  |     <Link target={cancel} hotkey="Escape"> | ||||||
|  |         <button class="btn btn-sm btn-outline w-full">Cancel</button> | ||||||
|  |     </Link> | ||||||
|  | </form> | ||||||
							
								
								
									
										62
									
								
								src/views/ManageCredentials.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								src/views/ManageCredentials.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | |||||||
|  | <script> | ||||||
|  |     import { onMount } from 'svelte'; | ||||||
|  |     import { slide, fade } from 'svelte/transition'; | ||||||
|  |     import { invoke } from '@tauri-apps/api/core'; | ||||||
|  |  | ||||||
|  |     import AwsCredential from './credentials/AwsCredential.svelte'; | ||||||
|  |     import Icon from '../ui/Icon.svelte'; | ||||||
|  |     import Nav from '../ui/Nav.svelte'; | ||||||
|  |  | ||||||
|  |     let show = false; | ||||||
|  |  | ||||||
|  |     let records = [] | ||||||
|  |     async function loadCreds() { | ||||||
|  |         records = await invoke('list_credentials'); | ||||||
|  |         console.log(records); | ||||||
|  |     } | ||||||
|  |     onMount(loadCreds); | ||||||
|  |  | ||||||
|  |     function newCred() { | ||||||
|  |         console.log('hello!'); | ||||||
|  |         records.push({ | ||||||
|  |             id: crypto.randomUUID(), | ||||||
|  |             name: '', | ||||||
|  |             is_default: false, | ||||||
|  |             credential: {type: 'AwsBase', AccessKeyId: '', SecretAccessKey: ''}, | ||||||
|  |             isNew: true, | ||||||
|  |         }); | ||||||
|  |         records = records; | ||||||
|  |     } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  |  | ||||||
|  | <Nav> | ||||||
|  |     <h1 slot="title" class="text-2xl font-bold">Credentials</h1> | ||||||
|  | </Nav> | ||||||
|  |  | ||||||
|  | <div class="max-w-xl mx-auto flex flex-col gap-y-4 justify-center"> | ||||||
|  |     <div class="divider"> | ||||||
|  |         <h2 class="text-xl font-bold">AWS Access Keys</h2> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     {#if records.length > 0} | ||||||
|  |         <div class="rounded-box border-2 border-neutral-content/30 divide-y-2 divide-neutral-content/30"> | ||||||
|  |             {#each records as record (record.id)} | ||||||
|  |                 <AwsCredential {record} on:update={loadCreds} /> | ||||||
|  |             {/each} | ||||||
|  |         </div> | ||||||
|  |         <button class="btn btn-primary btn-wide mx-auto" on:click={newCred}> | ||||||
|  |             <Icon name="plus-circle-mini" class="size-5" /> | ||||||
|  |             Add | ||||||
|  |         </button> | ||||||
|  |     {:else} | ||||||
|  |         <div class="flex flex-col gap-6 items-center rounded-box border-2 border-dashed border-neutral-content/30 p-6"> | ||||||
|  |             <div>You have no saved AWS credentials.</div> | ||||||
|  |             <button class="btn btn-primary btn-wide mx-auto" on:click={newCred}> | ||||||
|  |                 <Icon name="plus-circle-mini" /> | ||||||
|  |                 Add | ||||||
|  |             </button> | ||||||
|  |         </div> | ||||||
|  |     {/if} | ||||||
|  |  | ||||||
|  | </div> | ||||||
							
								
								
									
										91
									
								
								src/views/approve/CollectResponse.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/views/approve/CollectResponse.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | |||||||
|  | <script> | ||||||
|  |     import { createEventDispatcher } from 'svelte'; | ||||||
|  |     import { appState, cleanupRequest } from '../../lib/state.js'; | ||||||
|  |  | ||||||
|  |     import Link from '../../ui/Link.svelte'; | ||||||
|  |     import KeyCombo from '../../ui/KeyCombo.svelte'; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     // Executable paths can be long, so ensure they only break on \ or / | ||||||
|  |     function breakPath(path) { | ||||||
|  |         return path.replace(/(\\|\/)/g, '$1<wbr>'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Extract executable name from full path | ||||||
|  |     const client = $appState.currentRequest.client; | ||||||
|  |     const m = client.exe?.match(/\/([^/]+?$)|\\([^\\]+?$)/); | ||||||
|  |     const appName = m[1] || m[2]; | ||||||
|  |  | ||||||
|  |     const dispatch = createEventDispatcher(); | ||||||
|  |  | ||||||
|  |     function setResponse(approval, base) { | ||||||
|  |         $appState.currentRequest.response = { | ||||||
|  |             id: $appState.currentRequest.id, | ||||||
|  |             approval, | ||||||
|  |             base, | ||||||
|  |         }; | ||||||
|  |         dispatch('response'); | ||||||
|  |     } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  |  | ||||||
|  | {#if $appState.currentRequest?.base} | ||||||
|  |     <div class="alert alert-warning shadow-lg"> | ||||||
|  |         <div> | ||||||
|  |             <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> | ||||||
|  |             <span> | ||||||
|  |                 WARNING: This application is requesting your base AWS credentials.  | ||||||
|  |                 These credentials are less secure than session credentials, since they don't expire automatically. | ||||||
|  |             </span> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  | {/if} | ||||||
|  |  | ||||||
|  | <div class="space-y-1 mb-4"> | ||||||
|  |     <h2 class="text-xl font-bold">{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.</h2> | ||||||
|  |  | ||||||
|  |     <div class="grid grid-cols-[auto_1fr] gap-x-3"> | ||||||
|  |         <div class="text-right">Path:</div> | ||||||
|  |         <code class="">{@html client.exe ? breakPath(client.exe) : 'Unknown'}</code> | ||||||
|  |         <div class="text-right">PID:</div> | ||||||
|  |         <code>{client.pid}</code> | ||||||
|  |     </div> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | <div class="w-full grid grid-cols-[1fr_auto] items-center gap-y-6"> | ||||||
|  |         <!-- Don't display the option to approve with session credentials if base was specifically requested --> | ||||||
|  |         {#if !$appState.currentRequest?.base} | ||||||
|  |             <h3 class="font-semibold"> | ||||||
|  |                 Approve with session credentials | ||||||
|  |             </h3> | ||||||
|  |             <Link target={() => setResponse('Approved', false)} hotkey="Enter" shift={true}> | ||||||
|  |                 <button class="w-full btn btn-success"> | ||||||
|  |                     <KeyCombo keys={['Shift', 'Enter']} /> | ||||||
|  |                 </button> | ||||||
|  |             </Link> | ||||||
|  |         {/if} | ||||||
|  |  | ||||||
|  |         <h3 class="font-semibold"> | ||||||
|  |             <span class="mr-2"> | ||||||
|  |                 {#if $appState.currentRequest?.base} | ||||||
|  |                     Approve | ||||||
|  |                 {:else} | ||||||
|  |                     Approve with base credentials | ||||||
|  |                 {/if} | ||||||
|  |             </span> | ||||||
|  |         </h3> | ||||||
|  |         <Link target={() => setResponse('Approved', true)} hotkey="Enter" shift={true} ctrl={true}> | ||||||
|  |             <button class="w-full btn btn-warning"> | ||||||
|  |                 <KeyCombo keys={['Ctrl', 'Shift', 'Enter']} /> | ||||||
|  |             </button> | ||||||
|  |         </Link> | ||||||
|  |  | ||||||
|  |         <h3 class="font-semibold"> | ||||||
|  |             <span class="mr-2">Deny</span> | ||||||
|  |         </h3> | ||||||
|  |         <Link target={() => setResponse('Denied', false)} hotkey="Escape"> | ||||||
|  |             <button class="w-full btn btn-error"> | ||||||
|  |                 <KeyCombo keys={['Esc']} /> | ||||||
|  |             </button> | ||||||
|  |         </Link> | ||||||
|  | </div> | ||||||
							
								
								
									
										29
									
								
								src/views/approve/ShowResponse.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/views/approve/ShowResponse.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | <script> | ||||||
|  |     import { draw, fade } from 'svelte/transition'; | ||||||
|  |  | ||||||
|  |     import { appState } from '../../lib/state.js'; | ||||||
|  |      | ||||||
|  |     let success = false; | ||||||
|  |     let error = null; | ||||||
|  |  | ||||||
|  |     let drawDuration = $appState.config.rehide_ms >= 750 ? 500 : 0; | ||||||
|  |     let fadeDuration = drawDuration * 0.6; | ||||||
|  |     let fadeDelay = drawDuration * 0.4; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  |  | ||||||
|  | <div class="flex flex-col h-screen items-center justify-center max-w-max m-auto"> | ||||||
|  |     {#if $appState.currentRequest.response.approval === 'Approved'} | ||||||
|  |         <svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"> | ||||||
|  |           <path in:draw="{{duration: drawDuration}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> | ||||||
|  |         </svg> | ||||||
|  |     {:else} | ||||||
|  |         <svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"> | ||||||
|  |             <path in:draw="{{duration: 500}}" stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> | ||||||
|  |         </svg> | ||||||
|  |     {/if} | ||||||
|  |  | ||||||
|  |     <div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold"> | ||||||
|  |         {$appState.currentRequest.response.approval}! | ||||||
|  |     </div> | ||||||
|  | </div> | ||||||
							
								
								
									
										157
									
								
								src/views/credentials/AwsCredential.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								src/views/credentials/AwsCredential.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,157 @@ | |||||||
|  | <script> | ||||||
|  |     import { createEventDispatcher } from 'svelte'; | ||||||
|  |     import { fade, slide } from 'svelte/transition'; | ||||||
|  |     import { invoke } from '@tauri-apps/api/core'; | ||||||
|  |  | ||||||
|  |     import ErrorAlert from '../../ui/ErrorAlert.svelte'; | ||||||
|  |     import Icon from '../../ui/Icon.svelte'; | ||||||
|  |  | ||||||
|  |     export let record | ||||||
|  |  | ||||||
|  |     import PassphraseInput from '../../ui/PassphraseInput.svelte'; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     const dispatch = createEventDispatcher(); | ||||||
|  |  | ||||||
|  |     // if record.credential is blank when component is first instantiated, this is | ||||||
|  |     // a newly-added credential, so show details so that data can be filled out | ||||||
|  |     let showDetails = record.isNew ? true : false; | ||||||
|  |  | ||||||
|  |     let localName = name; | ||||||
|  |     let local = JSON.parse(JSON.stringify(record)); | ||||||
|  |     $: isModified = JSON.stringify(local) !== JSON.stringify(record); | ||||||
|  |  | ||||||
|  |     let error, alert; | ||||||
|  |     async function saveCredential() { | ||||||
|  |         try { | ||||||
|  |             await invoke('save_credential', {cred: local}); | ||||||
|  |             dispatch('update'); | ||||||
|  |             showDetails = false; | ||||||
|  |         } | ||||||
|  |         catch (e) { | ||||||
|  |             if (error) alert.shake(); | ||||||
|  |             error = e; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     let confirmDelete; | ||||||
|  |     function conditionalDelete() { | ||||||
|  |         if (!record.isNew) { | ||||||
|  |             confirmDelete.showModal(); | ||||||
|  |         } | ||||||
|  |         else { | ||||||
|  |             deleteCredential(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async function deleteCredential() { | ||||||
|  |         try { | ||||||
|  |             if (!record.isNew) { | ||||||
|  |  | ||||||
|  |                 await invoke('delete_credential', {id: record.id}); | ||||||
|  |             } | ||||||
|  |             dispatch('update'); | ||||||
|  |         } | ||||||
|  |         catch (e) { | ||||||
|  |             if (error) alert.shake(); | ||||||
|  |             error = e; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  |  | ||||||
|  | <div transition:slide|local={{duration: record.isNew ? 300 : 0}} class="px-6 py-4 space-y-4"> | ||||||
|  |     <div class="flex items-center gap-x-4"> | ||||||
|  |         <h3 class="text-lg font-bold">{record.name}</h3> | ||||||
|  |  | ||||||
|  |         {#if record.is_default} | ||||||
|  |             <span class="badge badge-secondary">Default</span> | ||||||
|  |         {/if} | ||||||
|  |  | ||||||
|  |         <div class="join ml-auto"> | ||||||
|  |             <button | ||||||
|  |                 type="button" | ||||||
|  |                 class="btn btn-sm btn-primary join-item" | ||||||
|  |                 on:click={() => showDetails = !showDetails} | ||||||
|  |             > | ||||||
|  |                 <Icon name="pencil" class="w-5 h-5" /> | ||||||
|  |             </button> | ||||||
|  |             <button | ||||||
|  |                 type="button" | ||||||
|  |                 class="btn btn-sm btn-error join-item" | ||||||
|  |                 on:click={conditionalDelete} | ||||||
|  |             > | ||||||
|  |                 <Icon name="trash" class="w-5 h-5" /> | ||||||
|  |             </button> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     {#if showDetails} | ||||||
|  |         {#if error} | ||||||
|  |             <ErrorAlert bind:this={alert}>{error}</ErrorAlert> | ||||||
|  |         {/if} | ||||||
|  |  | ||||||
|  |         <form | ||||||
|  |             transition:slide|local={{duration: 200}} | ||||||
|  |             class="space-y-4" | ||||||
|  |             on:submit|preventDefault={saveCredential} | ||||||
|  |         > | ||||||
|  |             <div class="grid grid-cols-[auto_1fr] items-center gap-4"> | ||||||
|  |                 {#if record.isNew} | ||||||
|  |                     <span class="justify-self-end">Name</span> | ||||||
|  |                     <input | ||||||
|  |                         type="text" | ||||||
|  |                         class="input input-bordered" | ||||||
|  |                         bind:value={local.name} | ||||||
|  |                     > | ||||||
|  |                 {/if} | ||||||
|  |  | ||||||
|  |                 <span class="justify-self-end">Key ID</span> | ||||||
|  |                 <input | ||||||
|  |                     type="text" | ||||||
|  |                     class="input input-bordered font-mono" | ||||||
|  |                     bind:value={local.credential.AccessKeyId} | ||||||
|  |                 > | ||||||
|  |  | ||||||
|  |                 <span>Secret key</span> | ||||||
|  |                 <div class="font-mono"> | ||||||
|  |                     <PassphraseInput bind:value={local.credential.SecretAccessKey} /> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="flex justify-between"> | ||||||
|  |                 <label class="label cursor-pointer justify-self-start space-x-4"> | ||||||
|  |                     <span class="label-text">Default for type</span> | ||||||
|  |                     <input type="checkbox" class="toggle toggle-secondary" bind:checked={local.is_default}> | ||||||
|  |                 </label> | ||||||
|  |                 {#if isModified} | ||||||
|  |                     <button | ||||||
|  |                         transition:fade={{duration: 100}} | ||||||
|  |                         type="submit" | ||||||
|  |                         class="btn btn-primary" | ||||||
|  |                         > | ||||||
|  |                             Save | ||||||
|  |                         </button> | ||||||
|  |                 {/if} | ||||||
|  |             </div> | ||||||
|  |         </form> | ||||||
|  |     {/if} | ||||||
|  |  | ||||||
|  |     <dialog bind:this={confirmDelete} class="modal"> | ||||||
|  |         <div class="modal-box"> | ||||||
|  |             <h3 class="text-lg font-bold">Delete AWS credential "{record.name}"?</h3> | ||||||
|  |             <div class="modal-action"> | ||||||
|  |                 <form method="dialog" class="flex gap-x-4"> | ||||||
|  |                     <button class="btn btn-outline">Cancel</button> | ||||||
|  |                     <button  | ||||||
|  |                         autofocus | ||||||
|  |                         class="btn btn-error" | ||||||
|  |                         on:click={deleteCredential} | ||||||
|  |                     >Delete</button> | ||||||
|  |                 </form> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </dialog> | ||||||
|  | </div> | ||||||
							
								
								
									
										84
									
								
								src/views/passphrase/EnterPassphrase.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/views/passphrase/EnterPassphrase.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | |||||||
|  | <script> | ||||||
|  |     import { createEventDispatcher } from 'svelte'; | ||||||
|  |     import { invoke } from '@tauri-apps/api/core'; | ||||||
|  |     import { appState } from '../../lib/state.js'; | ||||||
|  |  | ||||||
|  |     import ErrorAlert from '../../ui/ErrorAlert.svelte'; | ||||||
|  |     import Link from '../../ui/Link.svelte'; | ||||||
|  |     import PassphraseInput from '../../ui/PassphraseInput.svelte'; | ||||||
|  |     import Spinner from '../../ui/Spinner.svelte'; | ||||||
|  |  | ||||||
|  |     export let cancellable = false; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     const dispatch = createEventDispatcher(); | ||||||
|  |  | ||||||
|  |     let alert, saving; | ||||||
|  |     let passphrase = ''; | ||||||
|  |     let confirmPassphrase = ''; | ||||||
|  |     let error = null; | ||||||
|  |  | ||||||
|  |     function confirm() { | ||||||
|  |         if (passphrase !== confirmPassphrase) { | ||||||
|  |             error = 'Passphrases do not match.'; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async function save() { | ||||||
|  |         if (passphrase === '' || passphrase !== confirmPassphrase) { | ||||||
|  |             alert.shake(); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         saving = true; | ||||||
|  |         try { | ||||||
|  |             await invoke('set_passphrase', {passphrase}); | ||||||
|  |             $appState.sessionStatus = 'unlocked'; | ||||||
|  |             dispatch('save'); | ||||||
|  |         } | ||||||
|  |         catch (e) { | ||||||
|  |             if (error) alert.shake(); | ||||||
|  |             error = e; | ||||||
|  |         } | ||||||
|  |         saving = false; | ||||||
|  |     } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  |  | ||||||
|  | <form class="form-control gap-y-4" on:submit|preventDefault={save}> | ||||||
|  |     {#if error} | ||||||
|  |         <ErrorAlert bind:this={alert}>{error}</ErrorAlert> | ||||||
|  |     {/if} | ||||||
|  |  | ||||||
|  |     <label class="form-control w-full"> | ||||||
|  |         <div class="label"> | ||||||
|  |             <span class="label-text">Passphrase</span> | ||||||
|  |         </div> | ||||||
|  |         <PassphraseInput bind:value={passphrase} placeholder="correct horse battery staple" /> | ||||||
|  |     </label> | ||||||
|  |  | ||||||
|  |     <label class="form-control w-full"> | ||||||
|  |         <div class="label"> | ||||||
|  |             <span class="label-text">Re-enter passphrase</span> | ||||||
|  |         </div> | ||||||
|  |         <PassphraseInput | ||||||
|  |             bind:value={confirmPassphrase} | ||||||
|  |             placeholder="correct horse battery staple" | ||||||
|  |             on:change={confirm} | ||||||
|  |         /> | ||||||
|  |     </label> | ||||||
|  |  | ||||||
|  |     <button type="submit" class="btn btn-primary"> | ||||||
|  |         {#if saving} | ||||||
|  |             <Spinner class="w-5 h-5" thickness="12"/> | ||||||
|  |         {:else} | ||||||
|  |             Submit | ||||||
|  |         {/if} | ||||||
|  |     </button> | ||||||
|  |  | ||||||
|  |     {#if cancellable} | ||||||
|  |         <Link target="Home" hotkey="Escape"> | ||||||
|  |             <button type="button" class="btn btn-outline btn-sm w-full">Cancel</button> | ||||||
|  |         </Link> | ||||||
|  |     {/if} | ||||||
|  | </form> | ||||||
		Reference in New Issue
	
	Block a user