Compare commits

...

17 Commits

Author SHA1 Message Date
Joseph Montanaro
3d5cbedae1 working basic flow 2022-12-19 15:26:44 -08:00
10fd1d6028 more work on establishing credentials 2022-12-14 14:52:16 -08:00
67705aa2d1 add credentials entry view 2022-12-13 21:50:34 -08:00
Joseph Montanaro
9055fa41aa switch to invokes instead of emits 2022-12-13 16:50:44 -08:00
Joseph Montanaro
48269855e5 start listing some specific threats 2022-12-13 10:58:52 -08:00
1e4e1c9a5f encrypt/decrypt and db interaction 2022-12-03 21:47:09 -08:00
196510e9a2 connect to db on startup 2022-12-02 22:59:13 -08:00
e423df8e51 add to migration 2022-11-30 21:45:43 -08:00
Joseph Montanaro
2cfde4d841 first migration 2022-11-30 16:27:59 -08:00
Joseph Montanaro
7d462645b4 update readme because why not 2022-11-30 16:04:14 -08:00
Joseph Montanaro
8c271281f7 add security document 2022-11-30 15:29:41 -08:00
Joseph Montanaro
234d9e0471 fix some things 2022-11-29 16:13:09 -08:00
Joseph Montanaro
397928b8f1 reorganize backend 2022-11-28 16:16:33 -08:00
c19b573b26 all is change; in change is all again made new 2022-11-27 22:03:15 -08:00
cee43342b9 event-based routing? 2022-11-23 17:11:44 -08:00
e37c07adb4 rework routing 2022-11-21 21:23:50 -08:00
Joseph Montanaro
636cdf1b8f navigate on credentials request 2022-11-21 16:20:59 -08:00
23 changed files with 1989 additions and 163 deletions

3
.gitignore vendored
View File

@ -1,8 +1,7 @@
dist
**/node_modules
src-tauri/target/
**/creddy.db
# just in case
credentials*

View File

@ -1 +1,24 @@
## Creddy: Low-friction AWS credential manager
## Creddy: Low-friction AWS credential helper
_Security at the expense of usability comes at the expense of security._ - Avi Douglen
**Creddy** is an AWS credential helper that focuses on improving security without interrupting your workflow (much). It works by mimicking the AWS Instance Metadata Service and requesting your approval before granting any application access to your AWS credentials. Additionally, the credentials it hands out are short-lived session credentials rather than long-lived credentials, meaning that even if they are compromised, the damage that the attacker can do is limited.
### What was wrong with all the existing AWS credential managers?
Most other AWS credential managers that I have seen differ in two ways.
**First**, they require the user to be _proactive_ instead of _reactive_, i.e. you must remember "this command will require AWS credentials" and invoke it in some special way. By contrast, Creddy waits patiently in the background until an application requests credentials, then asks for your approval before proceeding. In most cases, this requires only a couple of keystrokes, after which your original operation continues as invoked. This completely prevents the frustrating workflow of:
```
$ aws do-something-interesting
...
...
Unable to locate credentials. You can configure credentials by running "aws configure".
# a deep sigh of the most profound resignation
$ with-aws-credentials aws do-something-interesting
```
**Second**, other credential managers are mostly backed by the system credential store. While this may sound like a good idea, it has a critical weakness: By default, on most systems, a user's credentials are accessible to _any process running as that user_. In other words, if your quick nodejs script happens to depend on a compromised module, congratulations: you have just given that module access to your AWS account.
By contrast, Creddy encrypts your main long-lived AWS credentials with a passphrase (using libsodium's `SecretBox`) and, importantly, _does not store that passphrase_. Although this means that you, the user, must re-enter the passphrase every time Creddy needs to generate a new session, this is normally only necessary about once per day. In my own opinion, this is a worthwhile tradeoff.

48
doc/security.md Normal file
View File

@ -0,0 +1,48 @@
## Security considerations
The following is a list of security features that I hope to add eventually, in approximately the order in which I expect to add them.
* Request logging, obviously.
* Disallow all Tauri APIs except for `invoke` and `emit`. The sole job of the frontend should be to collect user interaction. Everything else should be mediated through the backend.
* Maximally-restrictive CSP - not sure if Tauri does this by default. Also not sure whether it will interfere with IPC to set a zero-access CSP.
* Allow user to specify a role to assume, so that role can be given narrower permissions. Allow falling back to the root credentials in the event that broader permissions are required. (Unsure about this one, is there a good way to make it low-friction?)
* To defend against the possibility that an attacker could replace, say, the `aws` executable with a malicious one that snarfs your credentials and then passes the command on to the real one, maybe track the path (and maybe even the hash) of the executable, and raise a warning if this is the first time we've seen that one? Using the hash would be safer, but would also introduce a lot of false positives, since every time the application gets updated it would trigger. On the other hand, users should presumably know when they've updated things, so maybe it would be ok. On the _other_ other hand, if somebody doesn't use `aws` very often then it might be weeks or months in between updating it and actually using the updated executable, in which case they probably won't remember that this is the first time they've used it since updating.
Another possible approach is to _watch_ the files in question, and alert the user whenever any of them changes. Presumably the user will know whether this change is expected or not.
* Downgrade privileges after launching. In particular, if possible, disallow any kind of outgoing network access (obviously we have to bind the listening socket, but maybe we can filter that down to _just_ the ability to bind that particular address/port) and filesystem access outside of state db. I think this is doable on Linux, although it may involve high levels of `seccomp` grossness. No idea whether it's possible on Windows. Probably possible on MacOS although it may require lengths to which I am currently unwilling to go (e.g. pay for a certificate from Apple or something.)
* "Panic button" - if a potential attack is detected (e.g. the user denies a request but Creddy discovers the request has already succeeded somehow), offer a one-click option to lock out the current IAM user. (Sadly, you can't revoke session tokens, so this is the only way to limit a potential compromise). Not sure how feasible this is, session credentials may be limited with regard to what kind of IAM operations they can carry out.)
* Some kind of Yubikey or other HST integration. (Optional, since not everyone will have a HST.) This comes in two flavors:
1. (Probably doable) Store the encryption key for the passphrase on the HST, and ask the HST to decrypt the passphrase instead of asking the user to enter it. This has the advantage of being a) lower-friction, since the user doesn't have to type in the passphrase, and b) more secure, since the application code never sees the encryption key.
2. (Less doable) Store the actual AWS secret key on the HST, and then ask the HST to just sign the whole `GetSessionToken` request. This requires that the HST support the exact signing algorithm required by AWS, which a) it probably doesn't, and b) is subject to change anyway. So this is probably not doable, but it's worth at least double-checking, since it would provide the maximum theoretical level of security. (That is, after initial setup, the application would never again see the long-lived AWS secret key.)
## Threat model
Who exactly are we defending against and why?
The basic idea behind Creddy is that it provides "gap coverage" between two wildly different security boundaries: 1) the older, user-based model, where all code executing as a given user is assumed to have the same level of trust, and 2) the newer, application-based model (most clearly seen on mobile devices) where that bondary instead exists around each _application_.
The unfortunate reality is that desktop environments are unlikely to adopt the latter model any time soon, if ever. This is primarily due to friction: Per-application security is a nightmare to manage. The only reason it works at all on mobile devices is because most mobile apps eschew the local device in favor of cloud-backed services where they can, e.g. for file storage. Arguably, the higher-friction trust model of mobile environments is in part _why_ mobile apps tend to be cloud-first.
Regardless, we live in a world where it's difficult to run untrusted code without giving it an inordinate level of access to the machine on which it runs. Creddy attempts to prevent that access from including your AWS credentials. The threat model is thus "untrusted code running under your user". This is especially likely to occur in the form of a supply-chain attack, where the compromised code is not your own but rather a dependency, or a dependency of a dependency, etc.
## Particular attacks
There are lots of ways that I can imagine someone might try to circumvent Creddy's protection. Most of them require that the attacker be targeting Creddy in particular, rather than just "AWS credentials generally". In addition, most of them are "noisy" - that is, there's a good chance that the attack will alert the user to the fact that they are being attacked. This is generally something attackers try to avoid, since an easily-detected attack is likely to be shut down before it can spread very far.
### Tricking Creddy into allowing a request that it shouldn't
If an attacker is able to compromise Creddy's frontend, e.g. via a JS library that Creddy relies on, they could forge "request accepted" responses and cause the backend to hand out credentials to an unauthorized client. Most likely, the user would immediately be alerted to the fact that Something Is Up because as soon as the request came in, Creddy would pop up requesting permission. When the user (presumably) denied the request, Creddy would discover that the request had already been approved - we could make this a high-alert situation because it would be unlikely to happen unless something fishy were going on. Additionally, the request and (hopefully) what executable made it would be logged.
### Tricking the user into allowing a request they didn't intend to
If an attacker can edit the user's .bashrc or similar, they could theoretically insert a function or pre-command hook that wraps, say, the `aws` command, and dump the credentials before continuing on with the user's command. This would most likely alert the user because either a) the attacker is hijacking the original `aws` command and thus it doesn't do what the user told it to, or b) the user's original `aws` command proceeds as normal after the malicious one, and the user is alerted by the second request where there should only have been one.
A similar but more-difficult-to-detect attack would be replacing the `aws` executable, or any other executable that is always expected to ask for AWS credentials, with a malicious wrapper that snarfs the credentials before passing them through to the original command. Creddy could defend against this to a certain extent by storing the hash of the executable, as discussed above.
### Pretending to be the user
Most desktop environments don't prevent applications from simulating user-input events such as mouse clicks and keypresses. An attacker could issue a credentials request, then immediately simulate whatever hotkey or mouse click Creddy normally interprets as "confirm this request". To mitigate this Creddy could implement a minimum time for which it _must_ be on screen before dismissal. The attacker could try to wait for the machine to be unattended before executing this attack, but this is chancy and could still result in detection. The request would still be logged in any case.
### Twiddling with Creddy's persistent state
The solutions to or mitigations for a lot of these attacks rely on Creddy being able to assume that its local database hasn't been tampered with. Unfortunately, given that our threat model is "other code running as the same user", this isn't a safe assumption.

1093
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,13 @@ serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.0.5", features = ["api-all"] }
sodiumoxide = "0.2.7"
tokio = { version = ">=1.19", features = ["full"] }
# futures = ">=0.3.21"
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] }
netstat2 = "0.9.1"
sysinfo = "0.26.8"
aws-types = "0.52.0"
aws-sdk-sts = "0.22.0"
aws-smithy-types = "0.52.0"
aws-config = "0.52.0"
[features]
# by default Tauri runs in production mode

View File

@ -0,0 +1,17 @@
-- Add migration script here
CREATE TABLE credentials (
access_key_id TEXT NOT NULL,
secret_key_enc BLOB NOT NULL,
salt BLOB NOT NULL,
nonce BLOB NOT NULL
);
CREATE TABLE config (
name TEXT,
data TEXT
);
CREATE TABLE clients (
name TEXT,
path TEXT
);

View File

@ -0,0 +1,51 @@
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
use sysinfo::{System, SystemExt, Pid, ProcessExt};
use crate::errors::*;
use crate::ipc::Client;
fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
let mut it = netstat2::iterate_sockets_info(
AddressFamilyFlags::IPV4,
ProtocolFlags::TCP
)?;
for (i, item) in it.enumerate() {
let sock_info = item?;
let proto_info = match sock_info.protocol_socket_info {
ProtocolSocketInfo::Tcp(tcp_info) => tcp_info,
ProtocolSocketInfo::Udp(_) => {continue;}
};
if proto_info.local_port == local_port
&& proto_info.remote_port == 12345
&& proto_info.local_addr == std::net::Ipv4Addr::LOCALHOST
&& proto_info.remote_addr == std::net::Ipv4Addr::LOCALHOST
{
return Ok(sock_info.associated_pids)
}
}
Ok(vec![])
}
// Theoretically, on some systems, multiple processes can share a socket. We have to
// account for this even though 99% of the time there will be only one.
pub fn get_clients(local_port: u16) -> Result<Vec<Client>, ClientInfoError> {
let mut clients = Vec::new();
let mut sys = System::new();
for p in get_associated_pids(local_port)? {
let pid = Pid::from(p as i32);
sys.refresh_process(pid);
let proc = sys.process(pid)
.ok_or(ClientInfoError::PidNotFound)?;
let client = Client {
pid: p,
exe: proc.exe().to_string_lossy().into_owned(),
};
clients.push(client);
}
Ok(clients)
}

180
src-tauri/src/errors.rs Normal file
View File

@ -0,0 +1,180 @@
use std::fmt::{Display, Formatter};
use std::convert::From;
use std::str::Utf8Error;
use sqlx::{
error::Error as SqlxError,
migrate::MigrateError,
};
// error during initial setup (primarily loading state from db)
pub enum SetupError {
InvalidRecord, // e.g. wrong size blob for nonce or salt
DbError(SqlxError),
}
impl From<SqlxError> for SetupError {
fn from(e: SqlxError) -> SetupError {
SetupError::DbError(e)
}
}
impl From<MigrateError> for SetupError {
fn from (e: MigrateError) -> SetupError {
SetupError::DbError(SqlxError::from(e))
}
}
impl Display for SetupError {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
match self {
SetupError::InvalidRecord => write!(f, "Malformed database record"),
SetupError::DbError(e) => write!(f, "Error from database: {e}"),
}
}
}
// error when attempting to tell a request handler whether to release or deny crednetials
pub enum SendResponseError {
NotFound, // no request with the given id
Abandoned, // request has already been closed by client
}
impl Display for SendResponseError {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
use SendResponseError::*;
match self {
NotFound => write!(f, "The specified command was not found."),
Abandoned => write!(f, "The specified request was closed by the client."),
}
}
}
// errors encountered while handling an HTTP request
pub enum RequestError {
StreamIOError(std::io::Error),
InvalidUtf8,
MalformedHttpRequest,
RequestTooLarge,
NoCredentials(GetCredentialsError),
ClientInfo(ClientInfoError),
Tauri(tauri::Error),
NoMainWindow,
}
impl From<tokio::io::Error> for RequestError {
fn from(e: std::io::Error) -> RequestError {
RequestError::StreamIOError(e)
}
}
impl From<Utf8Error> for RequestError {
fn from(_e: Utf8Error) -> RequestError {
RequestError::InvalidUtf8
}
}
impl From<GetCredentialsError> for RequestError {
fn from (e: GetCredentialsError) -> RequestError {
RequestError::NoCredentials(e)
}
}
impl From<ClientInfoError> for RequestError {
fn from(e: ClientInfoError) -> RequestError {
RequestError::ClientInfo(e)
}
}
impl From<tauri::Error> for RequestError {
fn from(e: tauri::Error) -> RequestError {
RequestError::Tauri(e)
}
}
impl Display for RequestError {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
use RequestError::*;
match self {
StreamIOError(e) => write!(f, "Stream IO error: {e}"),
InvalidUtf8 => write!(f, "Could not decode UTF-8 from bytestream"),
MalformedHttpRequest => write!(f, "Maformed HTTP request"),
RequestTooLarge => write!(f, "HTTP request too large"),
NoCredentials(GetCredentialsError::Locked) => write!(f, "Recieved go-ahead but app is locked"),
NoCredentials(GetCredentialsError::Empty) => write!(f, "Received go-ahead but no credentials are known"),
ClientInfo(ClientInfoError::PidNotFound) => write!(f, "Could not resolve PID of client process."),
ClientInfo(ClientInfoError::NetstatError(e)) => write!(f, "Error getting client socket details: {e}"),
Tauri(e) => write!(f, "Tauri error: {e}"),
NoMainWindow => write!(f, "No main application window found"),
}
}
}
pub enum GetCredentialsError {
Locked,
Empty,
}
pub type AwsTokenError = aws_sdk_sts::types::SdkError<aws_sdk_sts::error::GetSessionTokenError>;
pub enum GetSessionError {
NoCredentials, // SDK returned successfully but credentials are None
SdkError(AwsTokenError),
}
impl From<AwsTokenError> for GetSessionError {
fn from(e: AwsTokenError) -> GetSessionError {
GetSessionError::SdkError(e)
}
}
impl Display for GetSessionError {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
match self {
GetSessionError::NoCredentials => write!(f, "Request completed successfully but no credentials were returned"),
GetSessionError::SdkError(e) => write!(f, "Error response from AWS: {e}")
}
}
}
pub enum UnlockError {
NotLocked,
NoCredentials,
BadPassphrase,
InvalidUtf8, // Somehow we got invalid utf-8 even though decryption succeeded
DbError(SqlxError),
GetSession(GetSessionError),
}
impl From<SqlxError> for UnlockError {
fn from (e: SqlxError) -> UnlockError {
match e {
SqlxError::RowNotFound => UnlockError::NoCredentials,
_ => UnlockError::DbError(e),
}
}
}
impl From<GetSessionError> for UnlockError {
fn from(e: GetSessionError) -> UnlockError {
UnlockError::GetSession(e)
}
}
impl Display for UnlockError {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
use UnlockError::*;
match self {
NotLocked => write!(f, "App is not locked"),
NoCredentials => write!(f, "No saved credentials were found"),
BadPassphrase => write!(f, "Invalid passphrase"),
InvalidUtf8 => write!(f, "Decrypted data was corrupted"),
DbError(e) => write!(f, "Database error: {e}"),
GetSession(e) => write!(f, "Failed to create AWS session: {e}")
}
}
}
// Errors encountered while trying to figure out who's on the other end of a request
pub enum ClientInfoError {
PidNotFound,
NetstatError(netstat2::error::Error),
}
impl From<netstat2::error::Error> for ClientInfoError {
fn from(e: netstat2::error::Error) -> ClientInfoError {
ClientInfoError::NetstatError(e)
}
}

View File

@ -1,42 +0,0 @@
use std::fmt::{Display, Formatter};
use std::convert::From;
use std::str::Utf8Error;
// use tokio::sync::oneshot::error::RecvError;
// Represents errors encountered while handling an HTTP request
pub enum RequestError {
StreamIOError(std::io::Error),
InvalidUtf8,
MalformedHttpRequest,
RequestTooLarge,
}
impl From<tokio::io::Error> for RequestError {
fn from(e: std::io::Error) -> RequestError {
RequestError::StreamIOError(e)
}
}
impl From<Utf8Error> for RequestError {
fn from(_e: Utf8Error) -> RequestError {
RequestError::InvalidUtf8
}
}
// impl From<RecvError> for RequestError {
// fn from (_e: RecvError) -> RequestError {
// RequestError::
// }
// }
impl Display for RequestError {
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
use RequestError::*;
match self {
StreamIOError(e) => write!(f, "Stream IO error: {e}"),
InvalidUtf8 => write!(f, "Could not decode UTF-8 from bytestream"),
MalformedHttpRequest => write!(f, "Maformed HTTP request"),
RequestTooLarge => write!(f, "HTTP request too large"),
}
}
}

70
src-tauri/src/ipc.rs Normal file
View File

@ -0,0 +1,70 @@
use serde::{Serialize, Deserialize};
use tauri::State;
use crate::state::{AppState, Session, Credentials};
#[derive(Clone, Serialize, Deserialize)]
pub struct Client {
pub pid: u32,
pub exe: String,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Request {
pub id: u64,
pub clients: Vec<Client>,
}
#[derive(Serialize, Deserialize)]
pub struct RequestResponse {
pub id: u64,
pub approval: Approval,
}
#[derive(Serialize, Deserialize)]
pub enum Approval {
Approved,
Denied,
}
#[tauri::command]
pub fn respond(response: RequestResponse, app_state: State<'_, AppState>) -> Result<(), String> {
app_state.send_response(response)
.map_err(|e| format!("Error responding to request: {e}"))
}
#[tauri::command]
pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Result<(), String> {
app_state.decrypt(&passphrase)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_session_status(app_state: State<'_, AppState>) -> String {
let session = app_state.session.read().unwrap();
match *session {
Session::Locked(_) => "locked".into(),
Session::Unlocked(_) => "unlocked".into(),
Session::Empty => "empty".into()
}
}
#[tauri::command]
pub async fn save_credentials(
credentials: Credentials,
passphrase: String,
app_state: State<'_, AppState>
) -> Result<(), String> {
app_state.save_creds(credentials, &passphrase)
.await
.map_err(|e| e.to_string())
}

View File

@ -4,27 +4,33 @@
)]
use std::str::FromStr;
// use tokio::runtime::Runtime;
mod storage;
mod http;
mod errors;
mod clientinfo;
mod ipc;
mod state;
mod server;
fn main() {
let initial_state = match state::AppState::new() {
Ok(state) => state,
Err(e) => {eprintln!("{}", e); return;}
};
tauri::Builder::default()
.manage(initial_state)
.invoke_handler(tauri::generate_handler![
ipc::unlock,
ipc::respond,
ipc::get_session_status,
ipc::save_credentials,
])
.setup(|app| {
let addr = std::net::SocketAddrV4::from_str("127.0.0.1:12345").unwrap();
tauri::async_runtime::spawn(http::serve(addr, app.handle()));
tauri::async_runtime::spawn(server::serve(addr, app.handle()));
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
// let addr = std::net::SocketAddrV4::from_str("127.0.0.1:12345").unwrap();
// let rt = Runtime::new().unwrap();
// rt.block_on(http::serve(addr)).unwrap();
// let creds = std::fs::read_to_string("credentials.json").unwrap();
// storage::save(&creds, "correct horse battery staple");
}

View File

@ -2,11 +2,13 @@ use std::io;
use std::net::SocketAddrV4;
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot;
use tauri::{AppHandle, Manager};
mod errors;
use errors::RequestError;
use crate::clientinfo;
use crate::errors::RequestError;
use crate::ipc::{Request, Approval};
pub async fn serve(addr: SocketAddrV4, app_handle: AppHandle) -> io::Result<()> {
@ -23,15 +25,15 @@ pub async fn serve(addr: SocketAddrV4, app_handle: AppHandle) -> io::Result<()>
});
},
Err(e) => {
println!("Error accepting connection: {e}");
eprintln!("Error accepting connection: {e}");
}
}
}
}
// it doesn't really return a String, we just need to placate the compiler
async fn stall(stream: &mut TcpStream) -> Result<String, tokio::io::Error> {
// it doesn't really return Approval, we just need to placate the compiler
async fn stall(stream: &mut TcpStream) -> Result<Approval, tokio::io::Error> {
let delay = std::time::Duration::from_secs(1);
loop {
tokio::time::sleep(delay).await;
@ -41,6 +43,22 @@ async fn stall(stream: &mut TcpStream) -> Result<String, tokio::io::Error> {
async fn handle(mut stream: TcpStream, app_handle: AppHandle) -> Result<(), RequestError> {
let (chan_send, chan_recv) = oneshot::channel();
let app_state = app_handle.state::<crate::state::AppState>();
let request_id = app_state.register_request(chan_send);
let peer_addr = match stream.peer_addr()? {
std::net::SocketAddr::V4(addr) => addr,
_ => unreachable!(), // we only listen on IPv4
};
let clients = clientinfo::get_clients(peer_addr.port())?;
let req = Request {id: request_id, clients};
app_handle.emit_all("credentials-request", req)?;
let window = app_handle.get_window("main").ok_or(RequestError::NoMainWindow)?;
window.show()?;
window.set_focus()?;
let mut buf = [0; 8192]; // it's what tokio's BufReader uses
let mut n = 0;
loop {
@ -55,11 +73,23 @@ async fn handle(mut stream: TcpStream, app_handle: AppHandle) -> Result<(), Requ
stream.write(b"Content-Type: application/json\r\n").await?;
stream.write(b"X-Creddy-delaying-tactic: ").await?;
let creds = tokio::select!{
r = stall(&mut stream) => r?, // this will never return Ok, just Err if it can't write to the stream
c = get_creds(&app_handle) => c?,
let approval = tokio::select!{
e = stall(&mut stream) => e?, // this will never return Ok, just Err if it can't write to the stream
r = chan_recv => r.unwrap(), // only panics if the sender is dropped without sending, which shouldn't happen
};
if matches!(approval, Approval::Denied) {
// because we own the stream, it gets closed when we return.
// Unfortunately we've already signaled 200 OK, there's no way around this -
// we have to write the status code first thing, and we have to assume that the user
// might need more time than that gives us (especially if entering the passphrase).
// Fortunately most AWS libs automatically retry if the request dies uncompleted, allowing
// us to respond with a proper error status.
return Ok(());
}
let creds = app_state.get_creds_serialized()?;
stream.write(b"\r\nContent-Length: ").await?;
stream.write(creds.as_bytes().len().to_string().as_bytes()).await?;
stream.write(b"\r\n\r\n").await?;
@ -67,33 +97,3 @@ async fn handle(mut stream: TcpStream, app_handle: AppHandle) -> Result<(), Requ
stream.write(b"\r\n\r\n").await?;
Ok(())
}
use tokio::io::{stdin, stdout, BufReader, AsyncBufReadExt};
use crate::storage;
use tokio::sync::oneshot;
async fn get_creds(app_handle: &AppHandle) -> io::Result<String> {
app_handle.emit_all("credentials-request", ()).unwrap();
// let mut out = stdout();
// out.write_all(b"Enter passphrase: ").await?;
// out.flush().await?;
let (tx, rx) = oneshot::channel();
app_handle.once_global("passphrase-entered", |event| {
match event.payload() {
Some(p) => {tx.send(p.to_string());}
None => {tx.send("".to_string());} // will fail decryption, we just need to unblock the outer function
}
});
// Error is only returned if the rx is closed/dropped before receiving, which should never happen
let passphrase = rx.await.unwrap();
// let mut passphrase = String::new();
// let mut reader = BufReader::new(stdin());
// reader.read_line(&mut passphrase).await?;
Ok(storage::load(&passphrase.trim()))
}

244
src-tauri/src/state.rs Normal file
View File

@ -0,0 +1,244 @@
use std::collections::HashMap;
use std::sync::RwLock;
use serde::{Serialize, Deserialize};
use tokio::sync::oneshot::Sender;
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions, sqlite::SqliteConnectOptions};
use sodiumoxide::crypto::{
pwhash,
pwhash::Salt,
secretbox,
secretbox::{Nonce, Key}
};
use tauri::async_runtime as runtime;
use crate::ipc;
use crate::errors::*;
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
pub enum Credentials {
#[serde(rename_all = "PascalCase")]
LongLived {
access_key_id: String,
secret_access_key: String,
},
#[serde(rename_all = "PascalCase")]
ShortLived {
access_key_id: String,
secret_access_key: String,
token: String,
expiration: String,
},
}
pub struct LockedCredentials {
access_key_id: String,
secret_key_enc: Vec<u8>,
salt: Salt,
nonce: Nonce,
}
pub enum Session {
Unlocked(Credentials),
Locked(LockedCredentials),
Empty,
}
pub struct AppState {
pub session: RwLock<Session>,
pub request_count: RwLock<u64>,
pub open_requests: RwLock<HashMap<u64, Sender<ipc::Approval>>>,
pool: SqlitePool,
}
impl AppState {
pub fn new() -> Result<Self, SetupError> {
let conn_opts = SqliteConnectOptions::new()
.filename("creddy.db")
.create_if_missing(true);
let pool_opts = SqlitePoolOptions::new();
let pool: SqlitePool = runtime::block_on(pool_opts.connect_with(conn_opts))?;
runtime::block_on(sqlx::migrate!().run(&pool))?;
let creds = runtime::block_on(Self::load_creds(&pool))?;
let state = AppState {
session: RwLock::new(creds),
request_count: RwLock::new(0),
open_requests: RwLock::new(HashMap::new()),
pool,
};
Ok(state)
}
async fn load_creds(pool: &SqlitePool) -> Result<Session, SetupError> {
let res = sqlx::query!("SELECT * FROM credentials")
.fetch_optional(pool)
.await?;
let row = match res {
Some(r) => r,
None => {return Ok(Session::Empty);}
};
let salt_buf: [u8; 32] = row.salt
.try_into()
.map_err(|_e| SetupError::InvalidRecord)?;
let nonce_buf: [u8; 24] = row.nonce
.try_into()
.map_err(|_e| SetupError::InvalidRecord)?;
let creds = LockedCredentials {
access_key_id: row.access_key_id,
secret_key_enc: row.secret_key_enc,
salt: Salt(salt_buf),
nonce: Nonce(nonce_buf),
};
Ok(Session::Locked(creds))
}
pub async fn save_creds(&self, creds: Credentials, passphrase: &str) -> Result<(), UnlockError> {
let (key_id, secret_key) = match creds {
Credentials::LongLived {access_key_id, secret_access_key} => {
(access_key_id, secret_access_key)
},
_ => unreachable!(),
};
let salt = pwhash::gen_salt();
let mut key_buf = [0; secretbox::KEYBYTES];
pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), &salt).unwrap();
let key = Key(key_buf);
// not sure we need both salt AND nonce given that we generate a
// fresh salt every time we encrypt, but better safe than sorry
let nonce = secretbox::gen_nonce();
let secret_key_enc = secretbox::seal(secret_key.as_bytes(), &nonce, &key);
sqlx::query(
"INSERT INTO credentials (access_key_id, secret_key_enc, salt, nonce)
VALUES (?, ?, ?, ?)"
)
.bind(&key_id)
.bind(&secret_key_enc)
.bind(&salt.0[0..])
.bind(&nonce.0[0..])
.execute(&self.pool)
.await?;
self.new_session(&key_id, &secret_key).await?;
Ok(())
}
pub fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
let count = {
let mut c = self.request_count.write().unwrap();
*c += 1;
c
};
let mut open_requests = self.open_requests.write().unwrap();
open_requests.insert(*count, chan); // `count` is the request id
*count
}
pub fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
let mut open_requests = self.open_requests.write().unwrap();
let chan = open_requests
.remove(&response.id)
.ok_or(SendResponseError::NotFound)
?;
chan.send(response.approval)
.map_err(|_e| SendResponseError::Abandoned)
}
pub async fn decrypt(&self, passphrase: &str) -> Result<(), UnlockError> {
let (key_id, secret) = {
// do this all in a block so rustc doesn't complain about holding a lock across an await
let session = self.session.read().unwrap();
let locked = match *session {
Session::Empty => {return Err(UnlockError::NoCredentials);},
Session::Unlocked(_) => {return Err(UnlockError::NotLocked);},
Session::Locked(ref c) => c,
};
let mut key_buf = [0; secretbox::KEYBYTES];
// pretty sure this only fails if we're out of memory
pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), &locked.salt).unwrap();
let decrypted = secretbox::open(&locked.secret_key_enc, &locked.nonce, &Key(key_buf))
.map_err(|_e| UnlockError::BadPassphrase)?;
let secret_str = String::from_utf8(decrypted).map_err(|_e| UnlockError::InvalidUtf8)?;
(locked.access_key_id.clone(), secret_str)
};
self.new_session(&key_id, &secret).await?;
Ok(())
}
pub fn get_creds_serialized(&self) -> Result<String, GetCredentialsError> {
let session = self.session.read().unwrap();
match *session {
Session::Unlocked(ref creds) => Ok(serde_json::to_string(creds).unwrap()),
Session::Locked(_) => Err(GetCredentialsError::Locked),
Session::Empty => Err(GetCredentialsError::Empty),
}
}
async fn new_session(&self, key_id: &str, secret_key: &str) -> Result<(), GetSessionError> {
let creds = aws_sdk_sts::Credentials::new(
key_id,
secret_key,
None, // token
None, // expiration
"creddy", // "provider name" apparently
);
let config = aws_config::from_env()
.credentials_provider(creds)
.load()
.await;
let client = aws_sdk_sts::Client::new(&config);
let resp = client.get_session_token()
.duration_seconds(43_200)
.send()
.await?;
let aws_session = resp.credentials().ok_or(GetSessionError::NoCredentials)?;
let access_key_id = aws_session.access_key_id()
.ok_or(GetSessionError::NoCredentials)?
.to_string();
let secret_access_key = aws_session.secret_access_key()
.ok_or(GetSessionError::NoCredentials)?
.to_string();
let token = aws_session.session_token()
.ok_or(GetSessionError::NoCredentials)?
.to_string();
let expiration = aws_session.expiration()
.ok_or(GetSessionError::NoCredentials)?
.fmt(aws_smithy_types::date_time::Format::DateTime)
.unwrap(); // only fails if the d/t is out of range, which it can't be for this format
let mut app_session = self.session.write().unwrap();
let session_creds = Credentials::ShortLived {
access_key_id,
secret_access_key,
token,
expiration,
};
println!("Got new session:\n{}", serde_json::to_string(&session_creds).unwrap());
*app_session = Session::Unlocked(session_creds);
Ok(())
}
}

View File

@ -1,31 +0,0 @@
use sodiumoxide::crypto::{pwhash, secretbox};
pub fn save(data: &str, passphrase: &str) {
let salt = pwhash::Salt([0; 32]); // yes yes, just for now
let mut kbuf = [0; secretbox::KEYBYTES];
pwhash::derive_key_interactive(&mut kbuf, passphrase.as_bytes(), &salt)
.expect("Couldn't compute password hash. Are you out of memory?");
let key = secretbox::Key(kbuf);
let nonce = secretbox::Nonce([0; 24]); // we don't care about e.g. replay attacks so this might be safe?
let encrypted = secretbox::seal(data.as_bytes(), &nonce, &key);
//todo: store in a database, along with salt, nonce, and hash parameters
std::fs::write("credentials.enc", &encrypted).expect("Failed to write file.");
//todo: key is automatically zeroed, but we should use 'zeroize' or something to zero out passphrase and data
}
pub fn load(passphrase: &str) -> String {
let salt = pwhash::Salt([0; 32]);
let mut kbuf = [0; secretbox::KEYBYTES];
pwhash::derive_key_interactive(&mut kbuf, passphrase.as_bytes(), &salt)
.expect("Couldn't compute password hash. Are you out of memory?");
let key = secretbox::Key(kbuf);
let nonce = secretbox::Nonce([0; 24]);
let encrypted = std::fs::read("credentials.enc").expect("Failed to read file.");
let decrypted = secretbox::open(&encrypted, &nonce, &key).expect("Failed to decrypt.");
String::from_utf8(decrypted).expect("Invalid utf-8")
}

View File

@ -58,6 +58,7 @@
"fullscreen": false,
"height": 600,
"resizable": true,
"label": "main",
"title": "Creddy",
"width": 800
}

View File

@ -1,14 +1,37 @@
<script>
import { emit, listen } from '@tauri-apps/api/event';
import Home from './views/Home.svelte';
import Approve from './views/Approve.svelte';
import queue from './lib/queue.js';
// listen('credentials-request', (event) => {
// const passphrase = prompt('Please enter your passphrase:');
// emit('passphrase-entered', passphrase);
// });
const VIEWS = import.meta.glob('./views/*.svelte', {eager: true});
let activeComponent = Approve;
window.emit = emit;
window.queue = queue;
var appState = {
currentRequest: null,
pendingRequests: queue(),
credentialStatus: 'locked',
}
window.appState = appState;
import { invoke } from '@tauri-apps/api/tauri';
window.invoke = invoke;
var currentView = VIEWS['./views/Home.svelte'].default;
window.currentView = currentView;
window.VIEWS = VIEWS;
function navigate(svelteEvent) {
const moduleName = `./views/${svelteEvent.detail.target}.svelte`;
currentView = VIEWS[moduleName].default;
}
window.navigate = navigate;
listen('credentials-request', (tauriEvent) => {
appState.pendingRequests.put(tauriEvent.payload);
console.log('Received request.');
});
</script>
<svelte:component this={activeComponent} />
<svelte:component this={currentView} on:navigate={navigate} bind:appState={appState} />

View File

@ -1,13 +1,19 @@
/* Simple asynchronous queue.
To make `get` blocking, we create a new promise and store its `resolve` function in `resolvers`.
The next time an item is added to the queue, it will be resolved.
*/
export default function() {
return {
items: [],
resolvers: []
resolvers: [],
put(item) {
this.items.push(item);
if (resolvers.length > 0) {
resolvers.shift().resolve();
let resolver = this.resolvers.shift();
if (resolver) {
resolver();
}
},

View File

@ -1,18 +1,46 @@
<script>
import { createEventDispatcher } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri';
export let appState;
const dispatch = createEventDispatcher();
async function approve() {
let status = await invoke('get_session_status');
if (status === 'unlocked') {
dispatch('navigate', {target: 'ShowApproved'});
}
else if (status === 'locked') {
dispatch('navigate', {target: 'Unlock'})
}
else {
dispatch('navigate', {target: 'EnterCredentials'});
}
}
function deny() {
dispatch('navigate', {target: 'ShowDenied'});
}
function handleHotkey(event) {
if (event.shiftKey && (event.code === 'Enter' || event.code === 'NumpadEnter')) {
approve();
}
}
</script>
<svelte:window on:keydown={handleHotkey} />
<h2 class="text-3xl text-gray-200">An application would like to access your AWS credentials.</h2>
<button on:click={() => dispatch('response', 'approved')}>
<button on:click={approve}>
<svg class="w-32 stroke-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button on:click={() => dispatch('response', 'denied')}>
<button on:click={deny}>
<svg class="w-32 stroke-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>

View File

@ -0,0 +1,41 @@
<script>
import { onMount, createEventDispatcher } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri';
export let appState;
const dispatch = createEventDispatcher();
let AccessKeyId, SecretAccessKey, passphrase
async function save() {
try {
console.log('Saving credentials.');
let credentials = {AccessKeyId, SecretAccessKey};
console.log(credentials);
await invoke('save_credentials', {credentials, passphrase});
if (appState.currentRequest) {
dispatch('navigate', {target: 'ShowApproved'})
}
else {
dispatch('navigate', {target: 'Home'})
}
}
catch (e) {
console.log("Error saving credentials:", e);
}
}
</script>
<form action="#" on:submit|preventDefault="{save}">
<div class="text-gray-200">AWS Access Key ID</div>
<input class="text-gray-200 bg-zinc-800" type="text" bind:value="{AccessKeyId}" />
<div class="text-gray-200">AWS Secret Access Key</div>
<input class="text-gray-200 bg-zinc-800" type="text" bind:value="{SecretAccessKey}" />
<div class="text-gray-200">Passphrase</div>
<input class="text-gray-200 bg-zinc-800" type="text" bind:value="{passphrase}" />
<input class="text-gray-200" type="submit" />
</form>

View File

@ -1 +1,18 @@
<script>
import { onMount, createEventDispatcher } from 'svelte';
export let appState;
const dispatch = createEventDispatcher();
onMount(async () => {
// will block until a request comes in
let req = await appState.pendingRequests.get();
appState.currentRequest = req;
console.log('Got credentials request from queue:');
console.log(req);
dispatch('navigate', {target: 'Approve'});
});
</script>
<h1 class="text-4xl text-gray-300">Creddy</h1>

View File

@ -0,0 +1,21 @@
<script>
import { onMount, createEventDispatcher } from 'svelte';
import { emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
export let appState;
onMount(async () => {
let response = {
id: appState.currentRequest.id,
approval: 'Approved',
}
await invoke('respond', {response});
appState.currentRequest = null;
});
const dispatch = createEventDispatcher();
window.setTimeout(() => dispatch('navigate', {target: 'Home'}), 3000);
</script>
<h1 class="text-4xl text-gray-300">Approved!</h1>

View File

@ -0,0 +1,21 @@
<script>
import { onMount, createEventDispatcher } from 'svelte';
import { emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
export let appState;
onMount(async () => {
let response = {
id: appState.currentRequest.id,
approval: 'Denied',
}
await invoke('respond', {response});
appState.currentRequest = null;
});
const dispatch = createEventDispatcher();
window.setTimeout(() => dispatch('navigate', {target: 'Home'}), 3000);
</script>
<h1 class="text-4xl text-gray-300">Denied!</h1>

32
src/views/Unlock.svelte Normal file
View File

@ -0,0 +1,32 @@
<script>
import { invoke } from '@tauri-apps/api/tauri';
import { createEventDispatcher } from 'svelte';
export let appState;
const dispatch = createEventDispatcher();
let passphrase = '';
async function unlock() {
console.log('invoking unlock command.')
try {
await invoke('unlock', {passphrase});
appState.credentialStatus = 'unlocked';
if (appState.currentRequest) {
dispatch('navigate', {target: 'ShowApproved'});
}
else {
dispatch('navigate', {target: 'Home'});
}
}
catch (e) {
console.log('Unlock error:', e);
}
}
</script>
<form action="#" on:submit|preventDefault="{unlock}">
<div class="text-gray-200">Enter your passphrase:</div>
<input class="text-gray-200 bg-zinc-800" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" />
</form>