Compare commits
2 Commits
d0a2532c27
...
8c668e51a6
Author | SHA1 | Date | |
---|---|---|---|
8c668e51a6 | |||
9928996fab |
2101
src-tauri/Cargo.lock
generated
2101
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -28,12 +28,11 @@ serde = { version = "1.0", features = ["derive"] }
|
|||||||
tauri = { version = "2.0.0-beta", features = ["tray-icon"] }
|
tauri = { version = "2.0.0-beta", features = ["tray-icon"] }
|
||||||
sodiumoxide = "0.2.7"
|
sodiumoxide = "0.2.7"
|
||||||
tokio = { version = ">=1.19", features = ["full"] }
|
tokio = { version = ">=1.19", features = ["full"] }
|
||||||
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] }
|
|
||||||
sysinfo = "0.26.8"
|
sysinfo = "0.26.8"
|
||||||
aws-types = "0.52.0"
|
aws-config = "1.5.3"
|
||||||
aws-sdk-sts = "0.22.0"
|
aws-types = "1.3.2"
|
||||||
aws-smithy-types = "0.52.0"
|
aws-sdk-sts = "1.33.0"
|
||||||
aws-config = "0.52.0"
|
aws-smithy-types = "1.2.0"
|
||||||
thiserror = "1.0.38"
|
thiserror = "1.0.38"
|
||||||
once_cell = "1.16.0"
|
once_cell = "1.16.0"
|
||||||
strum = "0.24"
|
strum = "0.24"
|
||||||
@ -53,6 +52,8 @@ rfd = "0.14.1"
|
|||||||
ssh-agent-lib = "0.4.0"
|
ssh-agent-lib = "0.4.0"
|
||||||
ssh-key = { version = "0.6.6", features = ["rsa", "ed25519", "encryption"] }
|
ssh-key = { version = "0.6.6", features = ["rsa", "ed25519", "encryption"] }
|
||||||
signature = "2.2.0"
|
signature = "2.2.0"
|
||||||
|
tokio-stream = "0.1.15"
|
||||||
|
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "uuid"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# by default Tauri runs in production mode
|
# by default Tauri runs in production mode
|
||||||
|
76
src-tauri/migrations/20240617142724_credential_split.sql
Normal file
76
src-tauri/migrations/20240617142724_credential_split.sql
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
-- app structure is changing - instead of passphrase/salt being per credential,
|
||||||
|
-- we now have a single app-wide key, which is generated by hashing the passphrase
|
||||||
|
-- with the known salt. To verify the key thus produced, we store a value previously
|
||||||
|
-- encrypted with that key, and attempt decryption once the key has been re-generated.
|
||||||
|
|
||||||
|
-- For migration purposes, we want convert the passphrase for the most recent set of
|
||||||
|
-- AWS credentials and turn it into the app-wide passphrase. The only value that we
|
||||||
|
-- have which is encrypted with that passphrase is the secret key for those credentials,
|
||||||
|
-- so we will just use that as the `verify_blob`. Feels a little weird, but oh well.
|
||||||
|
WITH latest_creds AS (
|
||||||
|
SELECT *
|
||||||
|
FROM credentials
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
|
||||||
|
INSERT INTO kv (name, value)
|
||||||
|
SELECT 'salt', salt FROM latest_creds
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'verify_nonce', nonce FROM latest_creds
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'verify_blob', secret_key_enc FROM latest_creds;
|
||||||
|
|
||||||
|
|
||||||
|
-- Credentials are now going to be stored in a main table
|
||||||
|
-- plus ancillary tables for type-specific data
|
||||||
|
|
||||||
|
-- stash existing AWS creds in temporary table so that we can remake it
|
||||||
|
CREATE TABLE aws_tmp (id, access_key_id, secret_key_enc, nonce, created_at);
|
||||||
|
|
||||||
|
INSERT INTO aws_tmp
|
||||||
|
SELECT randomblob(16), access_key_id, secret_key_enc, nonce, created_at
|
||||||
|
FROM credentials
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
-- we only ever used one at a time in the past
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- new master credentials table
|
||||||
|
DROP TABLE credentials;
|
||||||
|
CREATE TABLE credentials (
|
||||||
|
-- id is a UUID so we can generate it on the frontend
|
||||||
|
id BLOB UNIQUE NOT NULL,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- populate with basic data from existing AWS credential
|
||||||
|
INSERT INTO credentials (id, name, type, created_at)
|
||||||
|
SELECT id, 'default', 'aws', created_at FROM aws_tmp;
|
||||||
|
|
||||||
|
-- new AWS-specific table
|
||||||
|
CREATE TABLE aws_credentials (
|
||||||
|
id BLOB UNIQUE NOT NULL,
|
||||||
|
access_key_id TEXT NOT NULL,
|
||||||
|
secret_key_enc BLOB NOT NULL,
|
||||||
|
nonce BLOB NOT NULL,
|
||||||
|
FOREIGN KEY(id) REFERENCES credentials(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- populate with AWS-specific data from existing credential
|
||||||
|
INSERT INTO aws_credentials (id, access_key_id, secret_key_enc, nonce)
|
||||||
|
SELECT id, access_key_id, secret_key_enc, nonce
|
||||||
|
FROM aws_tmp;
|
||||||
|
|
||||||
|
-- done with this now
|
||||||
|
DROP TABLE aws_tmp;
|
||||||
|
|
||||||
|
|
||||||
|
-- SSH keys are the new hotness
|
||||||
|
CREATE TABLE ssh_keys (
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
public_key BLOB NOT NULL,
|
||||||
|
private_key_enc BLOB NOT NULL,
|
||||||
|
nonce BLOB NOT NULL
|
||||||
|
);
|
@ -23,7 +23,7 @@ use tauri::menu::MenuItem;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{self, AppConfig},
|
config::{self, AppConfig},
|
||||||
credentials::Session,
|
credentials::AppSession,
|
||||||
ipc,
|
ipc,
|
||||||
server::Server,
|
server::Server,
|
||||||
errors::*,
|
errors::*,
|
||||||
@ -45,10 +45,14 @@ pub fn run() -> tauri::Result<()> {
|
|||||||
.plugin(tauri_plugin_global_shortcut::Builder::default().build())
|
.plugin(tauri_plugin_global_shortcut::Builder::default().build())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
ipc::unlock,
|
ipc::unlock,
|
||||||
|
ipc::lock,
|
||||||
|
ipc::set_passphrase,
|
||||||
ipc::respond,
|
ipc::respond,
|
||||||
ipc::get_session_status,
|
ipc::get_session_status,
|
||||||
ipc::signal_activity,
|
ipc::signal_activity,
|
||||||
ipc::save_credentials,
|
ipc::save_credential,
|
||||||
|
ipc::delete_credential,
|
||||||
|
ipc::list_credentials,
|
||||||
ipc::get_config,
|
ipc::get_config,
|
||||||
ipc::save_config,
|
ipc::save_config,
|
||||||
ipc::launch_terminal,
|
ipc::launch_terminal,
|
||||||
@ -109,7 +113,7 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
|
|||||||
err => err?,
|
err => err?,
|
||||||
};
|
};
|
||||||
|
|
||||||
let session = Session::load(&pool).await?;
|
let app_session = AppSession::load(&pool).await?;
|
||||||
Server::start(app.handle().clone())?;
|
Server::start(app.handle().clone())?;
|
||||||
|
|
||||||
config::set_auto_launch(conf.start_on_login)?;
|
config::set_auto_launch(conf.start_on_login)?;
|
||||||
@ -128,12 +132,11 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
|
|||||||
.map(|names| names.split(':').any(|n| n == "GNOME"))
|
.map(|names| names.split(':').any(|n| n == "GNOME"))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// if session is empty, this is probably the first launch, so don't autohide
|
|
||||||
if !conf.start_minimized || is_first_launch {
|
if !conf.start_minimized || is_first_launch {
|
||||||
show_main_window(&app.handle())?;
|
show_main_window(&app.handle())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let state = AppState::new(conf, session, pool, setup_errors, desktop_is_gnome);
|
let state = AppState::new(conf, app_session, pool, setup_errors, desktop_is_gnome);
|
||||||
app.manage(state);
|
app.manage(state);
|
||||||
|
|
||||||
// make sure we do this after managing app state, so that it doesn't panic
|
// make sure we do this after managing app state, so that it doesn't panic
|
||||||
|
@ -12,7 +12,6 @@ use clap::{
|
|||||||
};
|
};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
use crate::credentials::Credentials;
|
|
||||||
use crate::errors::*;
|
use crate::errors::*;
|
||||||
use crate::server::{Request, Response};
|
use crate::server::{Request, Response};
|
||||||
use crate::shortcuts::ShortcutAction;
|
use crate::shortcuts::ShortcutAction;
|
||||||
@ -80,9 +79,10 @@ pub fn parser() -> Command<'static> {
|
|||||||
|
|
||||||
pub fn get(args: &ArgMatches) -> Result<(), CliError> {
|
pub fn get(args: &ArgMatches) -> Result<(), CliError> {
|
||||||
let base = args.get_one("base").unwrap_or(&false);
|
let base = args.get_one("base").unwrap_or(&false);
|
||||||
let output = match get_credentials(*base)? {
|
let output = match make_request(&Request::GetAwsCredentials { base: *base })? {
|
||||||
Credentials::Base(creds) => serde_json::to_string(&creds).unwrap(),
|
Response::AwsBase(creds) => serde_json::to_string(&creds).unwrap(),
|
||||||
Credentials::Session(creds) => serde_json::to_string(&creds).unwrap(),
|
Response::AwsSession(creds) => serde_json::to_string(&creds).unwrap(),
|
||||||
|
r => return Err(RequestError::Unexpected(r).into()),
|
||||||
};
|
};
|
||||||
println!("{output}");
|
println!("{output}");
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -98,16 +98,17 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
|
|||||||
let mut cmd = ChildCommand::new(cmd_name);
|
let mut cmd = ChildCommand::new(cmd_name);
|
||||||
cmd.args(cmd_line);
|
cmd.args(cmd_line);
|
||||||
|
|
||||||
match get_credentials(base)? {
|
match make_request(&Request::GetAwsCredentials { base })? {
|
||||||
Credentials::Base(creds) => {
|
Response::AwsBase(creds) => {
|
||||||
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
||||||
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
||||||
},
|
},
|
||||||
Credentials::Session(creds) => {
|
Response::AwsSession(creds) => {
|
||||||
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
||||||
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
||||||
cmd.env("AWS_SESSION_TOKEN", creds.session_token);
|
cmd.env("AWS_SESSION_TOKEN", creds.session_token);
|
||||||
}
|
},
|
||||||
|
r => return Err(RequestError::Unexpected(r).into()),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@ -157,16 +158,6 @@ pub fn invoke_shortcut(args: &ArgMatches) -> Result<(), CliError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn get_credentials(base: bool) -> Result<Credentials, RequestError> {
|
|
||||||
let req = Request::GetAwsCredentials { base };
|
|
||||||
match make_request(&req) {
|
|
||||||
Ok(Response::Aws(creds)) => Ok(creds),
|
|
||||||
Ok(r) => Err(RequestError::Unexpected(r)),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn make_request(req: &Request) -> Result<Response, RequestError> {
|
async fn make_request(req: &Request) -> Result<Response, RequestError> {
|
||||||
let mut data = serde_json::to_string(req).unwrap();
|
let mut data = serde_json::to_string(req).unwrap();
|
||||||
|
@ -1,17 +1,31 @@
|
|||||||
|
use std::fmt::{self, Formatter};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use aws_config::BehaviorVersion;
|
||||||
|
use aws_smithy_types::date_time::{DateTime, Format};
|
||||||
|
use chacha20poly1305::XNonce;
|
||||||
use serde::{
|
use serde::{
|
||||||
Serialize,
|
Serialize,
|
||||||
Deserialize,
|
Deserialize,
|
||||||
Serializer,
|
Serializer,
|
||||||
Deserializer,
|
Deserializer,
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use serde::de::{self, Visitor};
|
||||||
|
use sqlx::{
|
||||||
|
SqlitePool,
|
||||||
|
types::Uuid,
|
||||||
|
};
|
||||||
|
use sqlx::error::{
|
||||||
|
Error as SqlxError,
|
||||||
|
};
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
use super::{Crypto, PersistentCredential};
|
use super::{Credential, Crypto, SaveCredential, PersistentCredential};
|
||||||
|
|
||||||
use crate::errors::*;
|
use crate::errors::*;
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
pub struct AwsBaseCredential {
|
pub struct AwsBaseCredential {
|
||||||
#[serde(default = "default_credentials_version")]
|
#[serde(default = "default_credentials_version")]
|
||||||
@ -20,6 +34,7 @@ pub struct AwsBaseCredential {
|
|||||||
pub secret_access_key: String,
|
pub secret_access_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl AwsBaseCredential {
|
impl AwsBaseCredential {
|
||||||
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
|
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
|
||||||
Self {version: 1, access_key_id, secret_access_key}
|
Self {version: 1, access_key_id, secret_access_key}
|
||||||
@ -27,48 +42,89 @@ impl AwsBaseCredential {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PersistentCredential for AwsBaseCredential {
|
impl PersistentCredential for AwsBaseCredential {
|
||||||
pub async fn save(&self, crypt: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
|
async fn save(&self, id: &Uuid, name: &str, crypto: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
|
||||||
let (nonce, ciphertext) = crypto.encrypt(self.secret_access_key.as_bytes())?;
|
let (nonce, ciphertext) = crypto.encrypt(self.secret_access_key.as_bytes())?;
|
||||||
sqlx::query!(
|
let nonce_bytes = &nonce.as_slice();
|
||||||
"INSERT INTO aws_credentials (
|
let res = sqlx::query!(
|
||||||
name,
|
"INSERT INTO credentials (id, name, type, created_at)
|
||||||
key_id,
|
VALUES (?, ?, 'aws', strftime('%s'))
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
type = excluded.type,
|
||||||
|
created_at = excluded.created_at;
|
||||||
|
|
||||||
|
INSERT OR REPLACE INTO aws_credentials (
|
||||||
|
id,
|
||||||
|
access_key_id,
|
||||||
secret_key_enc,
|
secret_key_enc,
|
||||||
nonce,
|
nonce
|
||||||
updated_at
|
|
||||||
)
|
)
|
||||||
VALUES ('main', ?, ?, ? strftime('%s'))
|
VALUES (?, ?, ?, ?);",
|
||||||
ON CONFLICT DO UPDATE SET
|
id,
|
||||||
key_id = excluded.key_id,
|
name,
|
||||||
secret_key_enc = excluded.secret_key_enc,
|
id, // for the second query
|
||||||
nonce = excluded.nonce
|
|
||||||
updated_at = excluded.updated_at",
|
|
||||||
self.access_key_id,
|
self.access_key_id,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
nonce,
|
nonce_bytes,
|
||||||
).execute(pool).await?;
|
).execute(pool).await;
|
||||||
|
|
||||||
Ok(())
|
match res {
|
||||||
|
Err(SqlxError::Database(e)) if e.code().as_deref() == Some("2067") => Err(SaveCredentialsError::Duplicate),
|
||||||
|
Err(e) => Err(SaveCredentialsError::DbError(e)),
|
||||||
|
Ok(_) => Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load(crypt: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
|
async fn load(name: &str, crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
|
||||||
let row = sqlx::query!("SELECT * FROM aws_credentials WHERE name = 'main'")
|
let row = sqlx::query!(
|
||||||
.fetch_optional(pool)
|
"SELECT c.name, a.access_key_id, a.secret_key_enc, a.nonce
|
||||||
|
FROM credentials c JOIN aws_credentials a ON a.id = c.id
|
||||||
|
WHERE c.name = ?",
|
||||||
|
name
|
||||||
|
).fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(LoadCredentialsError::NoCredentials);
|
.ok_or(LoadCredentialsError::NoCredentials)?;
|
||||||
|
|
||||||
|
let nonce = XNonce::clone_from_slice(&row.nonce);
|
||||||
|
let secret_key_bytes = crypto.decrypt(&nonce, &row.secret_key_enc)?;
|
||||||
|
let secret_key = String::from_utf8(secret_key_bytes)
|
||||||
|
.map_err(|_| LoadCredentialsError::InvalidData)?;
|
||||||
|
|
||||||
|
Ok(AwsBaseCredential::new(row.access_key_id, secret_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(crypto: &Crypto, pool: &SqlitePool) -> Result<Vec<SaveCredential>, LoadCredentialsError> {
|
||||||
|
let mut rows = sqlx::query!(
|
||||||
|
"SELECT c.id, c.name, a.access_key_id, a.secret_key_enc, a.nonce
|
||||||
|
FROM credentials c JOIN aws_credentials a ON a.id = c.id"
|
||||||
|
).fetch(pool);
|
||||||
|
|
||||||
|
let mut creds = Vec::new();
|
||||||
|
|
||||||
|
while let Some(row) = rows.try_next().await? {
|
||||||
|
let nonce = XNonce::clone_from_slice(&row.nonce);
|
||||||
|
let secret_key_bytes = crypto.decrypt(&nonce, &row.secret_key_enc)?;
|
||||||
|
let secret_key = String::from_utf8(secret_key_bytes)
|
||||||
|
.map_err(|_| LoadCredentialsError::InvalidData)?;
|
||||||
|
let aws = AwsBaseCredential::new(row.access_key_id, secret_key);
|
||||||
|
|
||||||
|
let id = Uuid::from_slice(&row.id)
|
||||||
|
.map_err(|_| LoadCredentialsError::InvalidData)?;
|
||||||
|
|
||||||
|
let cred = SaveCredential {
|
||||||
|
id,
|
||||||
|
name: row.name,
|
||||||
|
credential: Credential::AwsBase(aws),
|
||||||
|
};
|
||||||
|
creds.push(cred);
|
||||||
|
}
|
||||||
|
|
||||||
let secret_key = crypto.decrypt(&row.nonce, &row.secret_key_enc)?;
|
|
||||||
let creds = Self {
|
|
||||||
version: 1,
|
|
||||||
access_key_id: row.key_id,
|
|
||||||
secret_access_key: secret_key,
|
|
||||||
};
|
|
||||||
Ok(creds)
|
Ok(creds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
pub struct AwsSessionCredential {
|
pub struct AwsSessionCredential {
|
||||||
#[serde(default = "default_credentials_version")]
|
#[serde(default = "default_credentials_version")]
|
||||||
@ -82,15 +138,15 @@ pub struct AwsSessionCredential {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AwsSessionCredential {
|
impl AwsSessionCredential {
|
||||||
pub async fn from_base(base: &BaseCredentials) -> Result<Self, GetSessionError> {
|
pub async fn from_base(base: &AwsBaseCredential) -> Result<Self, GetSessionError> {
|
||||||
let req_creds = aws_sdk_sts::Credentials::new(
|
let req_creds = aws_sdk_sts::config::Credentials::new(
|
||||||
&base.access_key_id,
|
&base.access_key_id,
|
||||||
&base.secret_access_key,
|
&base.secret_access_key,
|
||||||
None, // token
|
None, // token
|
||||||
None, //expiration
|
None, //expiration
|
||||||
"Creddy", // "provider name" apparently
|
"Creddy", // "provider name" apparently
|
||||||
);
|
);
|
||||||
let config = aws_config::from_env()
|
let config = aws_config::defaults(BehaviorVersion::latest())
|
||||||
.credentials_provider(req_creds)
|
.credentials_provider(req_creds)
|
||||||
.load()
|
.load()
|
||||||
.await;
|
.await;
|
||||||
@ -101,27 +157,14 @@ impl AwsSessionCredential {
|
|||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let aws_session = resp.credentials().ok_or(GetSessionError::EmptyResponse)?;
|
let aws_session = resp.credentials.ok_or(GetSessionError::EmptyResponse)?;
|
||||||
|
|
||||||
let access_key_id = aws_session.access_key_id()
|
let session_creds = AwsSessionCredential {
|
||||||
.ok_or(GetSessionError::EmptyResponse)?
|
|
||||||
.to_string();
|
|
||||||
let secret_access_key = aws_session.secret_access_key()
|
|
||||||
.ok_or(GetSessionError::EmptyResponse)?
|
|
||||||
.to_string();
|
|
||||||
let session_token = aws_session.session_token()
|
|
||||||
.ok_or(GetSessionError::EmptyResponse)?
|
|
||||||
.to_string();
|
|
||||||
let expiration = aws_session.expiration()
|
|
||||||
.ok_or(GetSessionError::EmptyResponse)?
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
let session_creds = SessionCredentials {
|
|
||||||
version: 1,
|
version: 1,
|
||||||
access_key_id,
|
access_key_id: aws_session.access_key_id,
|
||||||
secret_access_key,
|
secret_access_key: aws_session.secret_access_key,
|
||||||
session_token,
|
session_token: aws_session.session_token,
|
||||||
expiration,
|
expiration: aws_session.expiration,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
@ -143,6 +186,9 @@ impl AwsSessionCredential {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn default_credentials_version() -> usize { 1 }
|
||||||
|
|
||||||
|
|
||||||
struct DateTimeVisitor;
|
struct DateTimeVisitor;
|
||||||
|
|
||||||
impl<'de> Visitor<'de> for DateTimeVisitor {
|
impl<'de> Visitor<'de> for DateTimeVisitor {
|
||||||
@ -172,3 +218,128 @@ where S: Serializer
|
|||||||
let time_str = exp.fmt(Format::DateTime).unwrap();
|
let time_str = exp.fmt(Format::DateTime).unwrap();
|
||||||
serializer.serialize_str(&time_str)
|
serializer.serialize_str(&time_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
fn test_creds() -> AwsBaseCredential {
|
||||||
|
AwsBaseCredential::new(
|
||||||
|
"AKIAIOSFODNN7EXAMPLE".into(),
|
||||||
|
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_creds_2() -> AwsBaseCredential {
|
||||||
|
AwsBaseCredential::new(
|
||||||
|
"AKIAIOSFODNN7EXAMPL2".into(),
|
||||||
|
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKE2".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_uuid() -> Uuid {
|
||||||
|
Uuid::try_parse("00000000-0000-0000-0000-000000000000").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_uuid_2() -> Uuid {
|
||||||
|
Uuid::try_parse("ffffffff-ffff-ffff-ffff-ffffffffffff").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_uuid_random() -> Uuid {
|
||||||
|
let bytes = Crypto::salt();
|
||||||
|
Uuid::from_slice(&bytes[..16]).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_save(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::random();
|
||||||
|
test_creds().save(&test_uuid_random(), "test", &crypt, &pool).await
|
||||||
|
.expect("Failed to save AWS credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_overwrite(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::fixed();
|
||||||
|
|
||||||
|
let creds = test_creds_2();
|
||||||
|
// overwite original creds with different test data
|
||||||
|
creds.save(&test_uuid(), "test", &crypt, &pool).await
|
||||||
|
.expect("Failed to update AWS credentials");
|
||||||
|
|
||||||
|
// make sure update went through
|
||||||
|
let loaded = AwsBaseCredential::load("test", &crypt, &pool).await.unwrap();
|
||||||
|
assert_eq!(creds, loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_duplicate_name(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::random();
|
||||||
|
|
||||||
|
let id = test_uuid_random();
|
||||||
|
let resp = test_creds().save(&id, "test", &crypt, &pool).await;
|
||||||
|
|
||||||
|
if !matches!(resp, Err(SaveCredentialsError::Duplicate)) {
|
||||||
|
panic!("Attempt to create duplicate entry returned {resp:?}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_load(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::fixed();
|
||||||
|
let loaded = AwsBaseCredential::load("test", &crypt, &pool).await.unwrap();
|
||||||
|
assert_eq!(test_creds(), loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_save_load(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::random();
|
||||||
|
let creds = test_creds();
|
||||||
|
creds.save(&test_uuid_random(), "test", &crypt, &pool).await.unwrap();
|
||||||
|
let loaded = AwsBaseCredential::load("test", &crypt, &pool).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(creds, loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_list(pool: SqlitePool) {
|
||||||
|
let crypt = Crypto::fixed();
|
||||||
|
let list = AwsBaseCredential::list(&crypt, &pool).await
|
||||||
|
.expect("Failed to list AWS credentials");
|
||||||
|
|
||||||
|
let first = SaveCredential {
|
||||||
|
id: test_uuid(),
|
||||||
|
name: "test".into(),
|
||||||
|
credential: Credential::AwsBase(test_creds()),
|
||||||
|
};
|
||||||
|
assert_eq!(&first, &list[0]);
|
||||||
|
|
||||||
|
let second = SaveCredential {
|
||||||
|
id: test_uuid_2(),
|
||||||
|
name: "test2".into(),
|
||||||
|
credential: Credential::AwsBase(test_creds_2()),
|
||||||
|
};
|
||||||
|
assert_eq!(&second, &list[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("aws_credentials"))]
|
||||||
|
async fn test_rekey(pool: SqlitePool) {
|
||||||
|
let old_crypt = Crypto::fixed();
|
||||||
|
let orig = AwsBaseCredential::list(&old_crypt, &pool).await.unwrap();
|
||||||
|
|
||||||
|
let new_crypt = Crypto::random();
|
||||||
|
AwsBaseCredential::rekey(&old_crypt, &new_crypt, &pool).await
|
||||||
|
.expect("Failed to re-key AWS credentials");
|
||||||
|
|
||||||
|
let rekeyed = AwsBaseCredential::list(&new_crypt, &pool).await.unwrap();
|
||||||
|
for (before, after) in orig.iter().zip(rekeyed.iter()) {
|
||||||
|
assert_eq!(before, after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
19
src-tauri/src/credentials/fixtures/aws_credentials.sql
Normal file
19
src-tauri/src/credentials/fixtures/aws_credentials.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
INSERT INTO credentials (id, name, type, created_at)
|
||||||
|
VALUES
|
||||||
|
(X'00000000000000000000000000000000', 'test', 'aws', strftime('%s')),
|
||||||
|
(X'ffffffffffffffffffffffffffffffff', 'test2', 'aws', strftime('%s'));
|
||||||
|
|
||||||
|
INSERT INTO aws_credentials (id, access_key_id, secret_key_enc, nonce)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
X'00000000000000000000000000000000',
|
||||||
|
'AKIAIOSFODNN7EXAMPLE',
|
||||||
|
X'B09ACDADD07E295A3FD9146D8D3672FA5C2518BFB15CF039E68820C42EFD3BC3BE3156ACF438C2C957EC113EF8617DBC71790EAFE39B3DE8',
|
||||||
|
X'DB777F2C6315DC0E12ADF322256E69D09D7FB586AAE614A6'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
X'ffffffffffffffffffffffffffffffff',
|
||||||
|
'AKIAIOSFODNN7EXAMPL2',
|
||||||
|
X'ED6125FF40EF6F61929DF5FFD7141CD2B5A302A51C20152156477F8CC77980C614AB1B212AC06983F3CED35C4F3C54D4EE38964859930FBF',
|
||||||
|
X'962396B78DAA98DFDCC0AC0C9B7D688EC121F5759EBA790A'
|
||||||
|
);
|
@ -1,3 +1,5 @@
|
|||||||
|
use std::fmt::{self, Debug, Formatter};
|
||||||
|
|
||||||
use argon2::{
|
use argon2::{
|
||||||
Argon2,
|
Argon2,
|
||||||
Algorithm,
|
Algorithm,
|
||||||
@ -12,32 +14,94 @@ use chacha20poly1305::{
|
|||||||
Aead,
|
Aead,
|
||||||
AeadCore,
|
AeadCore,
|
||||||
KeyInit,
|
KeyInit,
|
||||||
Error as AeadError,
|
|
||||||
generic_array::GenericArray,
|
generic_array::GenericArray,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{
|
||||||
use sqlx::{FromRow, SqlitePool};
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
Serializer,
|
||||||
|
Deserializer,
|
||||||
|
};
|
||||||
|
use serde::de::{self, Visitor};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use sqlx::types::Uuid;
|
||||||
|
|
||||||
|
use crate::errors::*;
|
||||||
use crate::kv;
|
use crate::kv;
|
||||||
|
|
||||||
mod aws;
|
mod aws;
|
||||||
pub use aws::{AwsBaseCredential, AwsSessionCredential};
|
pub use aws::{AwsBaseCredential, AwsSessionCredential};
|
||||||
|
|
||||||
|
|
||||||
pub enum CredentialKind {
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
AwsBase,
|
pub enum Credential {
|
||||||
AwsSession,
|
AwsBase(AwsBaseCredential),
|
||||||
|
AwsSession(AwsSessionCredential),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub trait PersistentCredential {
|
// we need a special type for listing structs because
|
||||||
async fn load(crypt: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError>;
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
async fn save(&self, crypt: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError>;
|
pub struct SaveCredential {
|
||||||
|
#[serde(serialize_with = "serialize_uuid")]
|
||||||
|
#[serde(deserialize_with = "deserialize_uuid")]
|
||||||
|
id: Uuid, // UUID so it can be generated on the frontend
|
||||||
|
name: String, // user-facing identifier so it can be changed
|
||||||
|
credential: Credential,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SaveCredential {
|
||||||
|
pub async fn save(&self, crypt: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
|
||||||
|
let cred = match &self.credential {
|
||||||
|
Credential::AwsBase(b) => b,
|
||||||
|
Credential::AwsSession(_) => return Err(SaveCredentialsError::NotPersistent),
|
||||||
|
};
|
||||||
|
|
||||||
|
cred.save(&self.id, &self.name, crypt, pool).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_uuid<S: Serializer>(u: &Uuid, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
s.serialize_str(u.as_hyphenated().encode_lower(&mut buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UuidVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for UuidVisitor {
|
||||||
|
type Value = Uuid;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
|
||||||
|
write!(formatter, "a hyphenated UUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: de::Error>(self, v: &str) -> Result<Uuid, E> {
|
||||||
|
Uuid::try_parse(v)
|
||||||
|
.map_err(|_| E::custom(format!("Could not interpret string as UUID: {v}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_uuid<'de, D: Deserializer<'de>>(ds: D) -> Result<Uuid, D::Error> {
|
||||||
|
ds.deserialize_str(UuidVisitor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized {
|
||||||
|
async fn load(name: &str, crypt: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError>;
|
||||||
|
async fn list(crypt: &Crypto, pool: &SqlitePool) -> Result<Vec<SaveCredential>, LoadCredentialsError>;
|
||||||
|
async fn save(&self, id: &Uuid, name: &str, crypt: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError>;
|
||||||
|
|
||||||
|
async fn rekey(old: &Crypto, new: &Crypto, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
|
||||||
|
for cred in Self::list(old, pool).await? {
|
||||||
|
cred.save(new, pool).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
pub enum AppSession {
|
pub enum AppSession {
|
||||||
Unlocked {
|
Unlocked {
|
||||||
salt: [u8; 32],
|
salt: [u8; 32],
|
||||||
@ -54,14 +118,14 @@ pub enum AppSession {
|
|||||||
impl AppSession {
|
impl AppSession {
|
||||||
pub fn new(passphrase: &str) -> Result<Self, CryptoError> {
|
pub fn new(passphrase: &str) -> Result<Self, CryptoError> {
|
||||||
let salt = Crypto::salt();
|
let salt = Crypto::salt();
|
||||||
let crypto = Crypto::new(passphrase, &salt);
|
let crypto = Crypto::new(passphrase, &salt)?;
|
||||||
Ok(Self::Unlocked {salt, crypto})
|
Ok(Self::Unlocked {salt, crypto})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unlock(self, passphrase: &str) -> Result<Self, UnlockError> {
|
pub fn unlock(&mut self, passphrase: &str) -> Result<(), UnlockError> {
|
||||||
let (salt, nonce, blob) = match self {
|
let (salt, nonce, blob) = match self {
|
||||||
Self::Empty => return Err(UnlockError::NoCredentials),
|
Self::Empty => return Err(UnlockError::NoCredentials),
|
||||||
Self::Unlocked => return Err(UnlockError::NotLocked),
|
Self::Unlocked {..} => return Err(UnlockError::NotLocked),
|
||||||
Self::Locked {salt, verify_nonce, verify_blob} => (salt, verify_nonce, verify_blob),
|
Self::Locked {salt, verify_nonce, verify_blob} => (salt, verify_nonce, verify_blob),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -69,61 +133,78 @@ impl AppSession {
|
|||||||
.map_err(|e| CryptoError::Argon2(e))?;
|
.map_err(|e| CryptoError::Argon2(e))?;
|
||||||
|
|
||||||
// if passphrase is incorrect, this will fail
|
// if passphrase is incorrect, this will fail
|
||||||
let verify = crypto.decrypt(&nonce, &blob)?;
|
let _verify = crypto.decrypt(&nonce, &blob)?;
|
||||||
|
|
||||||
Ok(Self::Unlocked{crypto, salt})
|
*self = Self::Unlocked {crypto, salt: *salt};
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load(pool: &SqlitePool) -> Result<Self, LoadKvError> {
|
pub async fn load(pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
|
||||||
match kv::load_bytes_multi!(pool, "salt", "verify_nonce", "verify_blob").await? {
|
match kv::load_bytes_multi!(pool, "salt", "verify_nonce", "verify_blob").await? {
|
||||||
Some((salt, verify_nonce, verify_blob)) => {
|
Some((salt, nonce, blob)) => {
|
||||||
Ok(Self::Locked {salt, verify_nonce, verify_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),
|
None => Ok(Self::Empty),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save(&self, pool: &SqlitePool) -> Result<(), LockError> {
|
pub async fn save(&self, pool: &SqlitePool) -> Result<(), SaveCredentialsError> {
|
||||||
let (salt, nonce, blob) = match self {
|
match self {
|
||||||
Self::Unlocked {salt, crypto} => {
|
Self::Unlocked {salt, crypto} => {
|
||||||
let (nonce, blob) = crypto.encrypt(b"correct horse battery staple")
|
let (nonce, blob) = crypto.encrypt(b"correct horse battery staple")?;
|
||||||
.map_err(|e| CryptoError::Aead(e))?;
|
kv::save_bytes(pool, "salt", salt).await?;
|
||||||
(salt, nonce, blob)
|
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?;
|
||||||
},
|
},
|
||||||
Self::Locked {salt, verify_nonce, verify_blob} => (salt, verify_nonce, verify_blob),
|
|
||||||
// "saving" an empty session just means doing nothing
|
// "saving" an empty session just means doing nothing
|
||||||
Self::Empty => return Ok(()),
|
Self::Empty => (),
|
||||||
};
|
};
|
||||||
|
|
||||||
kv::save(pool, "salt", salt).await?;
|
|
||||||
kv::save(pool, "verify_nonce", nonce).await?;
|
|
||||||
kv::save(pool, "verify_blob", blob).await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn try_encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec<u8>), CryptoError> {
|
pub fn try_get_crypto(&self) -> Result<&Crypto, GetCredentialsError> {
|
||||||
let crypto = match self {
|
match self {
|
||||||
Self::Empty => Err(GetCredentialsError::Empty),
|
Self::Empty => Err(GetCredentialsError::Empty),
|
||||||
Self::Locked => Err(GetCredentialsError::Locked),
|
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,
|
Self::Unlocked {crypto, ..} => crypto,
|
||||||
}?;
|
};
|
||||||
let res = crypto.encrypt(data)?;
|
let res = crypto.encrypt(data)?;
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn try_decrypt(&self, nonce: XNonce, data: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
pub fn try_decrypt(&self, nonce: XNonce, data: &[u8]) -> Result<Vec<u8>, GetCredentialsError> {
|
||||||
let crypto = match self {
|
let crypto = match self {
|
||||||
Self::Empty => Err(GetCredentialsError::Empty),
|
Self::Empty => return Err(GetCredentialsError::Empty),
|
||||||
Self::Locked => Err(GetCredentialsError::Locked),
|
Self::Locked {..} => return Err(GetCredentialsError::Locked),
|
||||||
Self::Unlocked {crypto, ..} => crypto,
|
Self::Unlocked {crypto, ..} => crypto,
|
||||||
}?;
|
};
|
||||||
let res = crypto.decrypt(nonce, data)?;
|
let res = crypto.decrypt(&nonce, data)?;
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Crypto {
|
pub struct Crypto {
|
||||||
cipher: XChaCha20Poly1305,
|
cipher: XChaCha20Poly1305,
|
||||||
}
|
}
|
||||||
@ -175,19 +256,58 @@ impl Crypto {
|
|||||||
Ok(Crypto { cipher })
|
Ok(Crypto { cipher })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn random() -> Crypto {
|
||||||
|
// salt and key are the same length, so we can just use this
|
||||||
|
let key = Crypto::salt();
|
||||||
|
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
|
||||||
|
Crypto { cipher }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn fixed() -> Crypto {
|
||||||
|
let key = [
|
||||||
|
1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
||||||
|
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
||||||
|
];
|
||||||
|
|
||||||
|
let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
|
||||||
|
Crypto { cipher }
|
||||||
|
}
|
||||||
|
|
||||||
fn salt() -> [u8; 32] {
|
fn salt() -> [u8; 32] {
|
||||||
let mut salt = [0; 32];
|
let mut salt = [0; 32];
|
||||||
OsRng.fill_bytes(&mut salt);
|
OsRng.fill_bytes(&mut salt);
|
||||||
salt
|
salt
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec<u8>), AeadError> {
|
fn encrypt(&self, data: &[u8]) -> Result<(XNonce, Vec<u8>), CryptoError> {
|
||||||
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
|
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
|
||||||
let ciphertext = self.cipher.encrypt(&nonce, data)?;
|
let ciphertext = self.cipher.encrypt(&nonce, data)?;
|
||||||
Ok((nonce, ciphertext))
|
Ok((nonce, ciphertext))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decrypt(&self, nonce: &XNonce, data: &[u8]) -> Result<Vec<u8>, AeadError> {
|
fn decrypt(&self, nonce: &XNonce, data: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
self.cipher.decrypt(nonce, data)
|
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::*;
|
||||||
|
|
||||||
|
// #[sqlx::test(fixtures("uuid_test"))]
|
||||||
|
// async fn save_uuid(pool: SqlitePool) {
|
||||||
|
// let u = Uuid::try_parse("7140b90c-bfbd-4394-9008-01b94f94ecf8").unwrap();
|
||||||
|
// sqlx::query!("INSERT INTO uuids (uuid) VALUES (?)", u).execute(pool).unwrap();
|
||||||
|
// panic!("done, go check db");
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
@ -6,8 +6,9 @@ use strum_macros::AsRefStr;
|
|||||||
|
|
||||||
use thiserror::Error as ThisError;
|
use thiserror::Error as ThisError;
|
||||||
use aws_sdk_sts::{
|
use aws_sdk_sts::{
|
||||||
types::SdkError as AwsSdkError,
|
error::SdkError as AwsSdkError,
|
||||||
error::GetSessionTokenError,
|
operation::get_session_token::GetSessionTokenError,
|
||||||
|
error::ProvideErrorMetadata,
|
||||||
};
|
};
|
||||||
use rfd::{
|
use rfd::{
|
||||||
AsyncMessageDialog,
|
AsyncMessageDialog,
|
||||||
@ -208,6 +209,12 @@ pub enum GetCredentialsError {
|
|||||||
Locked,
|
Locked,
|
||||||
#[error("No credentials are known")]
|
#[error("No credentials are known")]
|
||||||
Empty,
|
Empty,
|
||||||
|
#[error(transparent)]
|
||||||
|
Crypto(#[from] CryptoError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Load(#[from] LoadCredentialsError),
|
||||||
|
#[error(transparent)]
|
||||||
|
GetSession(#[from] GetSessionError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -245,8 +252,8 @@ pub enum UnlockError {
|
|||||||
pub enum LockError {
|
pub enum LockError {
|
||||||
#[error("App is not unlocked")]
|
#[error("App is not unlocked")]
|
||||||
NotUnlocked,
|
NotUnlocked,
|
||||||
#[error("Database error: {0}")]
|
#[error(transparent)]
|
||||||
DbError(#[from] SqlxError),
|
LoadCredentials(#[from] LoadCredentialsError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Setup(#[from] SetupError),
|
Setup(#[from] SetupError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
@ -261,19 +268,33 @@ pub enum SaveCredentialsError {
|
|||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
DbError(#[from] SqlxError),
|
DbError(#[from] SqlxError),
|
||||||
#[error("Encryption error: {0}")]
|
#[error("Encryption error: {0}")]
|
||||||
Encryption(#[from] chacha20poly1305::Error),
|
Crypto(#[from] CryptoError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Session(#[from] GetCredentialsError),
|
||||||
|
#[error("App is locked")]
|
||||||
|
Locked,
|
||||||
|
#[error("Credential is temporary and cannot be saved")]
|
||||||
|
NotPersistent,
|
||||||
|
#[error("A credential with that name already exists")]
|
||||||
|
Duplicate,
|
||||||
|
// rekeying is fundamentally a save operation,
|
||||||
|
// but involves loading in order to re-save
|
||||||
|
#[error(transparent)]
|
||||||
|
LoadCredentials(#[from] LoadCredentialsError),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, ThisError, AsRefStr)]
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
pub enum LoadCredentialsError {
|
pub enum LoadCredentialsError {
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
DbError(#[from] SqlxError),
|
DbError(#[from] SqlxError),
|
||||||
#[error("Encryption error: {0}")]
|
#[error("Invalid passphrase")] // pretty sure this is the only way decryption fails
|
||||||
Encryption(#[from] chacha20poly1305::Error),
|
Encryption(#[from] CryptoError),
|
||||||
#[error("Credentials not found")]
|
#[error("Credentials not found")]
|
||||||
NoCredentials,
|
NoCredentials,
|
||||||
#[error("Could not decode credentials: {0}")]
|
#[error("Could not decode credential data")]
|
||||||
Invalid(#[from] serde_json::Error),
|
InvalidData,
|
||||||
|
#[error(transparent)]
|
||||||
|
LoadKv(#[from] LoadKvError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -292,6 +313,10 @@ pub enum CryptoError {
|
|||||||
Argon2(#[from] argon2::Error),
|
Argon2(#[from] argon2::Error),
|
||||||
#[error("Invalid passphrase")] // I think this is the only way decryption fails
|
#[error("Invalid passphrase")] // I think this is the only way decryption fails
|
||||||
Aead(#[from] chacha20poly1305::aead::Error),
|
Aead(#[from] chacha20poly1305::aead::Error),
|
||||||
|
#[error("App is currently locked")]
|
||||||
|
Locked,
|
||||||
|
#[error("No passphrase has been specified")]
|
||||||
|
Empty,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -409,6 +434,8 @@ impl_serialize_basic!(GetCredentialsError);
|
|||||||
impl_serialize_basic!(ClientInfoError);
|
impl_serialize_basic!(ClientInfoError);
|
||||||
impl_serialize_basic!(WindowError);
|
impl_serialize_basic!(WindowError);
|
||||||
impl_serialize_basic!(LockError);
|
impl_serialize_basic!(LockError);
|
||||||
|
impl_serialize_basic!(SaveCredentialsError);
|
||||||
|
impl_serialize_basic!(LoadCredentialsError);
|
||||||
|
|
||||||
|
|
||||||
impl Serialize for HandlerError {
|
impl Serialize for HandlerError {
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
|
use sqlx::types::Uuid;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::credentials::{Session,BaseCredentials};
|
use crate::credentials::{
|
||||||
|
AppSession,
|
||||||
|
SaveCredential
|
||||||
|
};
|
||||||
use crate::errors::*;
|
use crate::errors::*;
|
||||||
use crate::clientinfo::Client;
|
use crate::clientinfo::Client;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@ -17,6 +21,32 @@ pub struct AwsRequestNotification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SshRequestNotification {
|
||||||
|
pub id: u64,
|
||||||
|
pub client: Client,
|
||||||
|
pub key_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum RequestNotification {
|
||||||
|
Aws(AwsRequestNotification),
|
||||||
|
Ssh(SshRequestNotification),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestNotification {
|
||||||
|
pub fn new_aws(id: u64, client: Client, base: bool) -> Self {
|
||||||
|
Self::Aws(AwsRequestNotification {id, client, base})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_ssh(id: u64, client: Client, key_name: String) -> Self {
|
||||||
|
Self::Ssh(SshRequestNotification {id, client, key_name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct RequestResponse {
|
pub struct RequestResponse {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
@ -44,13 +74,25 @@ pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Resul
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn lock(app_state: State<'_, AppState>) -> Result<(), LockError> {
|
||||||
|
app_state.lock().await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_passphrase(passphrase: &str, app_state: State<'_, AppState>) -> Result<(), SaveCredentialsError> {
|
||||||
|
app_state.set_passphrase(passphrase).await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String, ()> {
|
pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String, ()> {
|
||||||
let session = app_state.session.read().await;
|
let session = app_state.app_session.read().await;
|
||||||
let status = match *session {
|
let status = match *session {
|
||||||
Session::Locked(_) => "locked".into(),
|
AppSession::Locked{..} => "locked".into(),
|
||||||
Session::Unlocked{..} => "unlocked".into(),
|
AppSession::Unlocked{..} => "unlocked".into(),
|
||||||
Session::Empty => "empty".into()
|
AppSession::Empty => "empty".into(),
|
||||||
};
|
};
|
||||||
Ok(status)
|
Ok(status)
|
||||||
}
|
}
|
||||||
@ -64,12 +106,25 @@ pub async fn signal_activity(app_state: State<'_, AppState>) -> Result<(), ()> {
|
|||||||
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn save_credentials(
|
pub async fn save_credential(
|
||||||
credentials: BaseCredentials,
|
cred: SaveCredential,
|
||||||
passphrase: String,
|
|
||||||
app_state: State<'_, AppState>
|
app_state: State<'_, AppState>
|
||||||
) -> Result<(), UnlockError> {
|
) -> Result<(), SaveCredentialsError> {
|
||||||
app_state.new_creds(credentials, &passphrase).await
|
app_state.save_credential(cred).await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_credential(id: &str, app_state: State<'_, AppState>) -> Result<(), SaveCredentialsError> {
|
||||||
|
let id = Uuid::try_parse(id)
|
||||||
|
.map_err(|_| LoadCredentialsError::NoCredentials)?;
|
||||||
|
app_state.delete_credential(&id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_credentials(app_state: State<'_, AppState>) -> Result<Vec<SaveCredential>, GetCredentialsError> {
|
||||||
|
app_state.list_credentials().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -44,45 +44,37 @@ pub async fn load_bytes(pool: &SqlitePool, name: &str) -> Result<Option<Vec<u8>>
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// pub async fn load_bytes_multi<const N: usize>(
|
|
||||||
// pool: &SqlitePool,
|
|
||||||
// names: [&str; N],
|
|
||||||
// ) -> Result<Option<[Vec<u8>; N]>, sqlx::Error> {
|
|
||||||
// // just use multiple queries, who cares
|
|
||||||
// let res: [Vec<u8>; N] = Default::default();
|
|
||||||
// for (i, name) in names.as_slice().iter().enumerate() {
|
|
||||||
// match load_bytes(pool, name).await? {
|
|
||||||
// Some(bytes) => res[i] = bytes,
|
|
||||||
// None => return Ok(None),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// Ok(res);
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
macro_rules! load_bytes_multi {
|
macro_rules! load_bytes_multi {
|
||||||
(
|
(
|
||||||
$pool:ident,
|
$pool:ident,
|
||||||
$($name:literal),*
|
$($name:literal),*
|
||||||
) => {
|
) => {
|
||||||
// wrap everything up in an immediately-invoked closure for easy short-circuiting
|
// wrap everything up in an async block for easy short-circuiting...
|
||||||
(|| {
|
async {
|
||||||
// a tuple, with one item for each repetition of $name
|
// ...returning a Result...
|
||||||
(
|
Ok::<_, sqlx::Error>(
|
||||||
// repeat this match block for every name
|
//containing an Option...
|
||||||
$(
|
Some(
|
||||||
// load_bytes returns Result<Option<_>>, the Result is handled by
|
// containing a tuple...
|
||||||
// the ? and we match on the Option
|
(
|
||||||
match load_bytes(pool, $name)? {
|
// ...with one item for each repetition of $name
|
||||||
Some(v) => v,
|
$(
|
||||||
None => return Ok(None)
|
// load_bytes returns Result<Option<_>>, the Result is handled by
|
||||||
},
|
// the ? and we match on the Option
|
||||||
)*
|
match crate::kv::load_bytes($pool, $name).await? {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return Ok(None)
|
||||||
|
},
|
||||||
|
)*
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
})()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) use load_bytes_multi;
|
||||||
|
|
||||||
|
|
||||||
// macro_rules! load_multi {
|
// macro_rules! load_multi {
|
||||||
// (
|
// (
|
||||||
|
@ -7,8 +7,11 @@ use tauri::{AppHandle, Manager};
|
|||||||
|
|
||||||
use crate::errors::*;
|
use crate::errors::*;
|
||||||
use crate::clientinfo::{self, Client};
|
use crate::clientinfo::{self, Client};
|
||||||
use crate::credentials::Credentials;
|
use crate::credentials::{
|
||||||
use crate::ipc::{Approval, AwsRequestNotification};
|
AwsBaseCredential,
|
||||||
|
AwsSessionCredential,
|
||||||
|
};
|
||||||
|
use crate::ipc::{Approval, RequestNotification};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::shortcuts::{self, ShortcutAction};
|
use crate::shortcuts::{self, ShortcutAction};
|
||||||
|
|
||||||
@ -40,7 +43,8 @@ pub enum Request {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub enum Response {
|
pub enum Response {
|
||||||
Aws(Credentials),
|
AwsBase(AwsBaseCredential),
|
||||||
|
AwsSession(AwsSessionCredential),
|
||||||
Empty,
|
Empty,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,8 +131,8 @@ async fn get_aws_credentials(
|
|||||||
// but ? returns immediately, and we want to unregister the request before returning
|
// but ? returns immediately, and we want to unregister the request before returning
|
||||||
// so we bundle it all up in an async block and return a Result so we can handle errors
|
// so we bundle it all up in an async block and return a Result so we can handle errors
|
||||||
let proceed = async {
|
let proceed = async {
|
||||||
let notification = AwsRequestNotification {id: request_id, client, base};
|
let notification = RequestNotification::new_aws(request_id, client, base);
|
||||||
app_handle.emit("credentials-request", ¬ification)?;
|
app_handle.emit("credential-request", ¬ification)?;
|
||||||
|
|
||||||
let response = tokio::select! {
|
let response = tokio::select! {
|
||||||
r = chan_recv => r?,
|
r = chan_recv => r?,
|
||||||
@ -141,12 +145,12 @@ async fn get_aws_credentials(
|
|||||||
match response.approval {
|
match response.approval {
|
||||||
Approval::Approved => {
|
Approval::Approved => {
|
||||||
if response.base {
|
if response.base {
|
||||||
let creds = state.base_creds_cloned().await?;
|
let creds = state.get_aws_base("default").await?;
|
||||||
Ok(Response::Aws(Credentials::Base(creds)))
|
Ok(Response::AwsBase(creds))
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
let creds = state.session_creds_cloned().await?;
|
let creds = state.get_aws_session("default").await?;
|
||||||
Ok(Response::Aws(Credentials::Session(creds)))
|
Ok(Response::AwsSession(creds.clone()))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Approval::Denied => Err(HandlerError::Denied),
|
Approval::Denied => Err(HandlerError::Denied),
|
||||||
|
@ -3,10 +3,11 @@ use std::time::Duration;
|
|||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::RwLock,
|
sync::{RwLock, RwLockReadGuard},
|
||||||
sync::oneshot::{self, Sender},
|
sync::oneshot::{self, Sender},
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
use sqlx::types::Uuid;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
Manager,
|
Manager,
|
||||||
async_runtime as rt,
|
async_runtime as rt,
|
||||||
@ -14,12 +15,16 @@ use tauri::{
|
|||||||
|
|
||||||
use crate::app;
|
use crate::app;
|
||||||
use crate::credentials::{
|
use crate::credentials::{
|
||||||
Session,
|
AppSession,
|
||||||
BaseCredentials,
|
AwsSessionCredential,
|
||||||
SessionCredentials,
|
|
||||||
};
|
};
|
||||||
use crate::{config, config::AppConfig};
|
use crate::{config, config::AppConfig};
|
||||||
use crate::ipc::{self, Approval, RequestResponse};
|
use crate::credentials::{
|
||||||
|
AwsBaseCredential,
|
||||||
|
SaveCredential,
|
||||||
|
PersistentCredential
|
||||||
|
};
|
||||||
|
use crate::ipc::{self, RequestResponse};
|
||||||
use crate::errors::*;
|
use crate::errors::*;
|
||||||
use crate::shortcuts;
|
use crate::shortcuts;
|
||||||
|
|
||||||
@ -101,7 +106,8 @@ impl VisibilityLease {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: RwLock<AppConfig>,
|
pub config: RwLock<AppConfig>,
|
||||||
pub session: RwLock<Session>,
|
pub app_session: RwLock<AppSession>,
|
||||||
|
pub aws_session: RwLock<Option<AwsSessionCredential>>,
|
||||||
pub last_activity: RwLock<OffsetDateTime>,
|
pub last_activity: RwLock<OffsetDateTime>,
|
||||||
pub request_count: RwLock<u64>,
|
pub request_count: RwLock<u64>,
|
||||||
pub waiting_requests: RwLock<HashMap<u64, Sender<RequestResponse>>>,
|
pub waiting_requests: RwLock<HashMap<u64, Sender<RequestResponse>>>,
|
||||||
@ -116,14 +122,15 @@ pub struct AppState {
|
|||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
session: Session,
|
app_session: AppSession,
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
setup_errors: Vec<String>,
|
setup_errors: Vec<String>,
|
||||||
desktop_is_gnome: bool,
|
desktop_is_gnome: bool,
|
||||||
) -> AppState {
|
) -> AppState {
|
||||||
AppState {
|
AppState {
|
||||||
config: RwLock::new(config),
|
config: RwLock::new(config),
|
||||||
session: RwLock::new(session),
|
app_session: RwLock::new(app_session),
|
||||||
|
aws_session: RwLock::new(None),
|
||||||
last_activity: RwLock::new(OffsetDateTime::now_utc()),
|
last_activity: RwLock::new(OffsetDateTime::now_utc()),
|
||||||
request_count: RwLock::new(0),
|
request_count: RwLock::new(0),
|
||||||
waiting_requests: RwLock::new(HashMap::new()),
|
waiting_requests: RwLock::new(HashMap::new()),
|
||||||
@ -135,12 +142,44 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn new_creds(&self, base_creds: BaseCredentials, passphrase: &str) -> Result<(), UnlockError> {
|
pub async fn save_credential(&self, cred: SaveCredential) -> Result<(), SaveCredentialsError> {
|
||||||
let locked = base_creds.encrypt(passphrase)?;
|
let session = self.app_session.read().await;
|
||||||
// do this first so that if it fails we don't save bad credentials
|
let crypto = session.try_get_crypto()?;
|
||||||
self.new_session(base_creds).await?;
|
cred.save(crypto, &self.pool).await
|
||||||
locked.save(&self.pool).await?;
|
}
|
||||||
|
|
||||||
|
pub async fn delete_credential(&self, id: &Uuid) -> Result<(), SaveCredentialsError> {
|
||||||
|
sqlx::query!("DELETE FROM credentials WHERE id = ?", id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_credentials(&self) -> Result<Vec<SaveCredential>, GetCredentialsError> {
|
||||||
|
let session = self.app_session.read().await;
|
||||||
|
let crypto = session.try_get_crypto()?;
|
||||||
|
let creds = AwsBaseCredential::list(crypto, &self.pool).await?;
|
||||||
|
// eventual extend this vec with other credential types
|
||||||
|
Ok(creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_passphrase(&self, passphrase: &str) -> Result<(), SaveCredentialsError> {
|
||||||
|
let mut cur_session = self.app_session.write().await;
|
||||||
|
if let AppSession::Locked {..} = *cur_session {
|
||||||
|
return Err(SaveCredentialsError::Locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_session = AppSession::new(passphrase)?;
|
||||||
|
if let AppSession::Unlocked {salt: _, ref crypto} = *cur_session {
|
||||||
|
AwsBaseCredential::rekey(
|
||||||
|
crypto,
|
||||||
|
new_session.try_get_crypto().expect("AppSession::new() should always return Unlocked"),
|
||||||
|
&self.pool,
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
new_session.save(&self.pool).await?;
|
||||||
|
*cur_session = new_session;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,11 +226,6 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
|
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
|
||||||
if let Approval::Approved = response.approval {
|
|
||||||
let mut session = self.session.write().await;
|
|
||||||
session.renew_if_expired().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut waiting_requests = self.waiting_requests.write().await;
|
let mut waiting_requests = self.waiting_requests.write().await;
|
||||||
waiting_requests
|
waiting_requests
|
||||||
.remove(&response.id)
|
.remove(&response.id)
|
||||||
@ -201,24 +235,17 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
|
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
|
||||||
let base_creds = match *self.session.read().await {
|
let mut session = self.app_session.write().await;
|
||||||
Session::Empty => {return Err(UnlockError::NoCredentials);},
|
session.unlock(passphrase)
|
||||||
Session::Unlocked{..} => {return Err(UnlockError::NotLocked);},
|
|
||||||
Session::Locked(ref locked) => locked.decrypt(passphrase)?,
|
|
||||||
};
|
|
||||||
// Read lock is dropped here, so this doesn't deadlock
|
|
||||||
self.new_session(base_creds).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn lock(&self) -> Result<(), LockError> {
|
pub async fn lock(&self) -> Result<(), LockError> {
|
||||||
let mut session = self.session.write().await;
|
let mut session = self.app_session.write().await;
|
||||||
match *session {
|
match *session {
|
||||||
Session::Empty => Err(LockError::NotUnlocked),
|
AppSession::Empty => Err(LockError::NotUnlocked),
|
||||||
Session::Locked(_) => Err(LockError::NotUnlocked),
|
AppSession::Locked{..} => Err(LockError::NotUnlocked),
|
||||||
Session::Unlocked{..} => {
|
AppSession::Unlocked{..} => {
|
||||||
*session = Session::load(&self.pool).await?;
|
*session = AppSession::load(&self.pool).await?;
|
||||||
|
|
||||||
let app_handle = app::APP.get().unwrap();
|
let app_handle = app::APP.get().unwrap();
|
||||||
app_handle.emit("locked", None::<usize>)?;
|
app_handle.emit("locked", None::<usize>)?;
|
||||||
@ -228,6 +255,29 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_aws_base(&self, name: &str) -> Result<AwsBaseCredential, GetCredentialsError> {
|
||||||
|
let app_session = self.app_session.read().await;
|
||||||
|
let crypto = app_session.try_get_crypto()?;
|
||||||
|
let creds = AwsBaseCredential::load(name, crypto, &self.pool).await?;
|
||||||
|
Ok(creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_aws_session(&self, name: &str) -> Result<RwLockReadGuard<'_, AwsSessionCredential>, GetCredentialsError> {
|
||||||
|
// yes, this sometimes results in double-fetching base credentials from disk
|
||||||
|
// I'm done trying to be optimal
|
||||||
|
{
|
||||||
|
let mut aws_session = self.aws_session.write().await;
|
||||||
|
if aws_session.is_none() || aws_session.as_ref().unwrap().is_expired() {
|
||||||
|
let base_creds = self.get_aws_base(name).await?;
|
||||||
|
*aws_session = Some(AwsSessionCredential::from_base(&base_creds).await?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we know this is safe, because we juse made sure of it
|
||||||
|
let s = RwLockReadGuard::map(self.aws_session.read().await, |opt| opt.as_ref().unwrap());
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn signal_activity(&self) {
|
pub async fn signal_activity(&self) {
|
||||||
let mut last_activity = self.last_activity.write().await;
|
let mut last_activity = self.last_activity.write().await;
|
||||||
*last_activity = OffsetDateTime::now_utc();
|
*last_activity = OffsetDateTime::now_utc();
|
||||||
@ -235,7 +285,7 @@ impl AppState {
|
|||||||
|
|
||||||
pub async fn should_auto_lock(&self) -> bool {
|
pub async fn should_auto_lock(&self) -> bool {
|
||||||
let config = self.config.read().await;
|
let config = self.config.read().await;
|
||||||
if !config.auto_lock || !self.is_unlocked().await {
|
if !config.auto_lock || self.is_locked().await {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,28 +294,9 @@ impl AppState {
|
|||||||
elapsed >= config.lock_after
|
elapsed >= config.lock_after
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_unlocked(&self) -> bool {
|
pub async fn is_locked(&self) -> bool {
|
||||||
let session = self.session.read().await;
|
let session = self.app_session.read().await;
|
||||||
matches!(*session, Session::Unlocked{..})
|
matches!(*session, AppSession::Locked {..})
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn base_creds_cloned(&self) -> Result<BaseCredentials, GetCredentialsError> {
|
|
||||||
let app_session = self.session.read().await;
|
|
||||||
let (base, _session) = app_session.try_get()?;
|
|
||||||
Ok(base.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn session_creds_cloned(&self) -> Result<SessionCredentials, GetCredentialsError> {
|
|
||||||
let app_session = self.session.read().await;
|
|
||||||
let (_base, session) = app_session.try_get()?;
|
|
||||||
Ok(session.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> {
|
|
||||||
let session = SessionCredentials::from_base(&base).await?;
|
|
||||||
let mut app_session = self.session.write().await;
|
|
||||||
*app_session = Session::Unlocked {base, session};
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register_terminal_request(&self) -> Result<(), ()> {
|
pub async fn register_terminal_request(&self) -> Result<(), ()> {
|
||||||
@ -285,3 +316,41 @@ impl AppState {
|
|||||||
*req = false;
|
*req = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::credentials::Crypto;
|
||||||
|
use sqlx::types::Uuid;
|
||||||
|
|
||||||
|
|
||||||
|
fn test_state(pool: SqlitePool) -> AppState {
|
||||||
|
let salt = [0u8; 32];
|
||||||
|
let crypto = Crypto::fixed();
|
||||||
|
AppState::new(
|
||||||
|
AppConfig::default(),
|
||||||
|
AppSession::Unlocked { salt, crypto },
|
||||||
|
pool,
|
||||||
|
vec![],
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[sqlx::test(fixtures("./credentials/fixtures/aws_credentials.sql"))]
|
||||||
|
fn test_delete_credential(pool: SqlitePool) {
|
||||||
|
let state = test_state(pool);
|
||||||
|
let id = Uuid::try_parse("00000000-0000-0000-0000-000000000000").unwrap();
|
||||||
|
state.delete_credential(&id).await.unwrap();
|
||||||
|
|
||||||
|
// ensure delete-cascade went through correctly
|
||||||
|
let res = AwsBaseCredential::load(
|
||||||
|
"test",
|
||||||
|
&Crypto::fixed(),
|
||||||
|
&state.pool,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
assert!(matches!(res, Err(LoadCredentialsError::NoCredentials)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
use crate::app::APP;
|
use crate::app::APP;
|
||||||
use crate::errors::*;
|
use crate::errors::*;
|
||||||
@ -23,44 +25,42 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> {
|
|||||||
cmd
|
cmd
|
||||||
};
|
};
|
||||||
|
|
||||||
// if session is locked or empty, wait for credentials from frontend
|
// if session is locked, wait for credentials from frontend
|
||||||
if !state.is_unlocked().await {
|
if state.is_locked().await {
|
||||||
app.emit("launch-terminal-request", ())?;
|
|
||||||
let lease = state.acquire_visibility_lease(0).await
|
let lease = state.acquire_visibility_lease(0).await
|
||||||
.map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually?
|
.map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually?
|
||||||
|
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
app.once("credentials-event", move |e| {
|
app.once("unlocked", move |_| {
|
||||||
let success = match e.payload() {
|
let _ = tx.send(());
|
||||||
"\"unlocked\"" | "\"entered\"" => true,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
let _ = tx.send(success);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if !rx.await.unwrap_or(false) {
|
let timeout = Duration::from_secs(60);
|
||||||
state.unregister_terminal_request().await;
|
tokio::select! {
|
||||||
return Ok(()); // request was canceled by user
|
// if the frontend is unlocked within 60 seconds, release visibility lock and proceed
|
||||||
|
_ = rx => lease.release(),
|
||||||
|
// otherwise, dump this request, but return Ok so we don't get an error popup
|
||||||
|
_ = sleep(timeout) => {
|
||||||
|
state.unregister_terminal_request().await;
|
||||||
|
eprintln!("WARNING: Request to launch terminal timed out after 60 seconds.");
|
||||||
|
return Ok(());
|
||||||
|
},
|
||||||
}
|
}
|
||||||
lease.release();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// more lock-management
|
// session should really be unlocked at this point, but if the frontend misbehaves
|
||||||
{
|
// (i.e. lies about unlocking) we could end up here with a locked session
|
||||||
let app_session = state.session.read().await;
|
// this will result in an error popup to the user (see main hotkey handler)
|
||||||
// session should really be unlocked at this point, but if the frontend misbehaves
|
if use_base {
|
||||||
// (i.e. lies about unlocking) we could end up here with a locked session
|
let base_creds = state.get_aws_base("default").await?;
|
||||||
// this will result in an error popup to the user (see main hotkey handler)
|
cmd.env("AWS_ACCESS_KEY_ID", &base_creds.access_key_id);
|
||||||
let (base_creds, session_creds) = app_session.try_get()?;
|
cmd.env("AWS_SECRET_ACCESS_KEY", &base_creds.secret_access_key);
|
||||||
if use_base {
|
}
|
||||||
cmd.env("AWS_ACCESS_KEY_ID", &base_creds.access_key_id);
|
else {
|
||||||
cmd.env("AWS_SECRET_ACCESS_KEY", &base_creds.secret_access_key);
|
let session_creds = state.get_aws_session("default").await?;
|
||||||
}
|
cmd.env("AWS_ACCESS_KEY_ID", &session_creds.access_key_id);
|
||||||
else {
|
cmd.env("AWS_SECRET_ACCESS_KEY", &session_creds.secret_access_key);
|
||||||
cmd.env("AWS_ACCESS_KEY_ID", &session_creds.access_key_id);
|
cmd.env("AWS_SESSION_TOKEN", &session_creds.session_token);
|
||||||
cmd.env("AWS_SECRET_ACCESS_KEY", &session_creds.secret_access_key);
|
|
||||||
cmd.env("AWS_SESSION_TOKEN", &session_creds.session_token);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let res = match cmd.spawn() {
|
let res = match cmd.spawn() {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user