almost finish refactoring PersistentCredential trait

This commit is contained in:
Joseph Montanaro 2024-06-26 15:01:07 -04:00
parent 37b44ddb2e
commit ce7d75f15a
21 changed files with 1287 additions and 459 deletions

View File

@ -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

View File

@ -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);
// }
// }
} }

View 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 {{ [...] }}")
}
}

View File

@ -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

View File

@ -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)
} }
} }

View 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)
}
}

View 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)
}
}

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>