almost finish refactoring PersistentCredential trait
This commit is contained in:
parent
37b44ddb2e
commit
ce7d75f15a
@ -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>
|
Loading…
x
Reference in New Issue
Block a user