Make login completely async and implement progress dialog.

This gives the user some indication what bwtui is doing and that it
isn't frozen.
This commit is contained in:
Christoph Heiss 2020-11-30 14:19:02 +01:00
parent 7e2587a7d0
commit c82ba73eba
Signed by: c8h4
GPG key ID: 8D9166DCF6A28E57
8 changed files with 412 additions and 357 deletions

2
Cargo.lock generated
View file

@ -122,7 +122,7 @@ checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
[[package]]
name = "bitwarden"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"aes",
"base64 0.13.0",

View file

@ -1,6 +1,6 @@
[package]
name = "bitwarden"
version = "0.1.0"
version = "0.2.0"
authors = ["Christoph Heiss <me@christoph-heiss.me>"]
edition = "2018"

View file

@ -1,35 +1,31 @@
// SPDX-License-Identifier: MIT
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use reqwest::header::{self, HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::cipher::{CipherString, CipherSuite};
use crate::api_definition::{
PreloginRequest, PreloginResponse, LoginRequest, LoginResponse, SyncResponse
};
use crate::cipher::CipherSuite;
const AUTH_URL: &str = "https://identity.bitwarden.com/connect/token";
const BASE_URL: &str = "https://api.bitwarden.com";
#[derive(Debug, failure::Fail)]
#[derive(Clone, Debug, failure::Fail)]
pub enum ApiError {
#[fail(display = "prelogin failed: {}", 0)]
PreloginFailed(String),
#[fail(display = "network error: failed to retrieve {}", 0)]
NetworkError(String),
#[fail(display = "authentication failed: {}", 0)]
LoginFailed(String),
#[fail(display = "authentication failed: wrong username or password")]
LoginFailed,
#[fail(display = "failed to retrieve {}: {}", endpoint, error)]
RequestFailed { endpoint: String, error: String },
#[fail(display = "failed to write sync data: {}", 0)]
VaultDataWriteFailed(String),
#[fail(display = "failed to read sync data: {}", 0)]
VaultDataReadFailed(String),
#[fail(display = "failed to sync vault: {}", 0)]
SyncFailed(String),
}
pub type ApiResult<T> = Result<T, ApiError>;
#[derive(Debug, Deserialize, Serialize)]
pub struct AuthData {
access_token: String,
@ -43,206 +39,23 @@ pub struct AuthData {
pub cipher: CipherSuite,
}
#[derive(Debug, Deserialize)]
struct PreloginResponseData {
#[serde(alias = "Kdf")]
kdf: u32,
#[serde(alias = "KdfIterations")]
kdf_iterations: u32,
}
#[derive(Debug, Deserialize)]
struct LoginResponseData {
access_token: String,
expires_in: usize,
token_type: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Profile {
#[serde(alias = "Object")]
object: String,
#[serde(alias = "Id")]
pub uuid: Uuid,
#[serde(alias = "Name")]
pub name: String,
#[serde(alias = "Email")]
pub email: String,
#[serde(alias = "EmailVerified")]
pub email_verified: bool,
#[serde(alias = "Premium")]
pub premium: bool,
#[serde(alias = "MasterPasswordHint")]
pub master_password_hint: Option<String>,
#[serde(alias = "Culture")]
pub language: String,
#[serde(alias = "TwoFactorEnabled")]
pub tfa_enabled: bool,
#[serde(alias = "Key")]
pub key: CipherString,
#[serde(alias = "PrivateKey")]
pub private_key: CipherString,
#[serde(alias = "SecurityStamp")]
pub security_stamp: String,
#[serde(alias = "Organizations")]
pub organizations: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Folder {
#[serde(alias = "Object")]
object: String,
#[serde(alias = "Id")]
pub uuid: Uuid,
#[serde(alias = "Name")]
pub name: CipherString,
#[serde(alias = "RevisionDate")]
pub last_changed: DateTime<Utc>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CipherEntryFields {
#[serde(alias = "Type")]
pub type_: usize,
#[serde(alias = "Name")]
pub name: CipherString,
#[serde(alias = "Value")]
pub value: CipherString,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CipherEntryHistory {
#[serde(alias = "Password")]
pub password: String,
#[serde(alias = "LastUsedDate")]
pub last_used_date: DateTime<Utc>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CipherEntryUriMatch {
#[serde(alias = "Uri")]
pub uri: CipherString,
#[serde(alias = "Match")]
pub match_: Option<usize>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CipherEntryData {
#[serde(alias = "Uri")]
pub uri: Option<CipherString>,
#[serde(alias = "Uris")]
pub uris: Option<Vec<CipherEntryUriMatch>>,
#[serde(alias = "Username")]
pub username: CipherString,
#[serde(alias = "Password")]
pub password: CipherString,
#[serde(alias = "PasswordRevisionDate")]
pub assword_last_changed: Option<DateTime<Utc>>,
#[serde(alias = "Totp")]
pub totp: Option<String>,
#[serde(alias = "Name")]
pub name: CipherString,
#[serde(alias = "Notes")]
pub notes: Option<String>,
#[serde(alias = "Fields")]
pub fields: Option<Vec<CipherEntryFields>>,
#[serde(alias = "PasswordHistory")]
pub password_history: Option<Vec<CipherEntryHistory>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CipherEntry {
#[serde(alias = "Object")]
object: String,
#[serde(alias = "CollectionIds")]
pub collection_ids: Vec<Uuid>,
#[serde(alias = "FolderId")]
pub folder_id: Option<Uuid>,
#[serde(alias = "Favorite")]
pub favorite: bool,
#[serde(alias = "Edit")]
pub edit: bool,
#[serde(alias = "Id")]
pub uuid: Uuid,
#[serde(alias = "OrganizationId")]
pub organization_id: Option<Uuid>,
#[serde(alias = "Type")]
pub type_: usize,
#[serde(alias = "Data")]
pub data: CipherEntryData,
#[serde(alias = "Name")]
pub name: CipherString,
#[serde(alias = "Notes")]
pub notes: Option<String>,
#[serde(alias = "Login", skip)]
pub login: Option<CipherEntryData>,
#[serde(alias = "Card")]
pub card: Option<String>,
#[serde(alias = "Identity")]
pub identity: Option<String>,
#[serde(alias = "SecureNote")]
pub secure_note: Option<String>,
#[serde(alias = "Fields")]
pub fields: Option<Vec<CipherEntryFields>>,
#[serde(alias = "PasswordHistory")]
pub password_history: Option<Vec<CipherEntryHistory>>,
#[serde(alias = "Attachments")]
pub attachments: Option<String>,
#[serde(alias = "OrganizationUseTotp")]
pub organization_tfa: bool,
#[serde(alias = "RevisionDate")]
pub last_changed: DateTime<Utc>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Domains {
// TODO
}
#[derive(Debug, Deserialize, Serialize)]
pub struct VaultData {
#[serde(alias = "Object")]
object: String,
#[serde(alias = "Profile")]
pub profile: Profile,
#[serde(alias = "Folders")]
pub folders: Vec<Folder>,
#[serde(alias = "Collections")]
pub collections: Vec<String>,
#[serde(alias = "Ciphers")]
pub ciphers: Vec<CipherEntry>,
#[serde(alias = "Domains", skip)]
domains: Option<Domains>,
}
#[derive(Debug)]
pub struct AppData {
pub auth: AuthData,
pub vault: VaultData,
}
fn perform_prelogin(
client: &reqwest::blocking::Client,
email: &str,
) -> Result<PreloginResponseData, ApiError> {
) -> ApiResult<PreloginResponse> {
let url = format!("{}/accounts/prelogin", BASE_URL);
let mut data = HashMap::new();
data.insert("email", email);
let data = PreloginRequest { email };
let response = client
.post(&url)
.json(&data)
.send()
.map_err(|e| ApiError::PreloginFailed(e.to_string()))?;
.map_err(|err| ApiError::NetworkError(err.to_string()))?;
if response.status().is_success() {
let data: PreloginResponseData = response.json()
.map_err(|e| ApiError::PreloginFailed(e.to_string()))?;
Ok(data)
response.json::<PreloginResponse>().map_err(|_| ApiError::LoginFailed)
} else {
Err(ApiError::PreloginFailed(format!("{:?}", response.status())))
Err(ApiError::LoginFailed)
}
}
@ -250,46 +63,44 @@ fn perform_token_auth(
client: &reqwest::blocking::Client,
email: &str,
cipher: &CipherSuite,
) -> Result<LoginResponseData, ApiError> {
) -> ApiResult<LoginResponse> {
let device_id = Uuid::new_v4().to_hyphenated().to_string();
let mut data = HashMap::new();
data.insert("grant_type", "password");
data.insert("username", email);
data.insert("scope", "api offline_access");
data.insert("client_id", "connector");
data.insert("deviceType", "3");
data.insert("deviceIdentifier", &device_id);
data.insert("deviceName", "bwtui");
data.insert("password", &cipher.master_key_hash);
let data = LoginRequest {
grant_type: "password",
username: email,
scope: "api offline_access",
client_id: "connector",
device_type: 3,
device_id: &device_id,
device_name: "bwtui",
password: &cipher.master_key_hash,
};
let response = client
.post(AUTH_URL)
.form(&data)
.send()
.map_err(|e| ApiError::LoginFailed(e.to_string()))?;
.map_err(|err| ApiError::NetworkError(err.to_string()))?;
if response.status().is_success() {
let data: LoginResponseData = response.json()
.map_err(|e| ApiError::LoginFailed(e.to_string()))?;
Ok(data)
response.json::<LoginResponse>().map_err(|_| ApiError::LoginFailed)
} else {
Err(ApiError::LoginFailed(format!("{:?}", response.status())))
Err(ApiError::LoginFailed)
}
}
pub fn authenticate(email: &str, password: &str) -> Result<AuthData, ApiError> {
pub fn authenticate(email: &str, password: &str) -> ApiResult<AuthData> {
let client = reqwest::blocking::Client::new();
let PreloginResponseData {
let PreloginResponse {
kdf,
kdf_iterations,
} = perform_prelogin(&client, email)?;
let cipher = CipherSuite::from(email, password, kdf_iterations);
let LoginResponseData {
let LoginResponse {
access_token,
expires_in,
token_type,
@ -305,14 +116,9 @@ pub fn authenticate(email: &str, password: &str) -> Result<AuthData, ApiError> {
})
}
pub fn sync(auth_data: &AuthData) -> Result<VaultData, ApiError> {
pub fn sync(auth_data: &AuthData) -> ApiResult<SyncResponse> {
let url = format!("{}/sync", BASE_URL);
let map_reqwest_err = |e: reqwest::Error| ApiError::RequestFailed {
endpoint: url.clone(),
error: e.to_string(),
};
let mut headers = HeaderMap::new();
let auth_header = format!("{} {}", auth_data.token_type, auth_data.access_token);
headers.insert(
@ -320,21 +126,16 @@ pub fn sync(auth_data: &AuthData) -> Result<VaultData, ApiError> {
HeaderValue::from_str(&auth_header).unwrap(),
);
let client = reqwest::blocking::Client::builder()
.default_headers(headers)
.build()
.map_err(map_reqwest_err)?;
let response = client.get(&url).send().map_err(map_reqwest_err)?;
let response = reqwest::blocking::Client::new()
.get(&url)
.headers(headers)
.send()
.map_err(|_| ApiError::NetworkError(url))?;
if response.status().is_success() {
let data: VaultData = response.json().map_err(map_reqwest_err)?;
Ok(data)
response.json::<SyncResponse>()
.map_err(|_| ApiError::SyncFailed("received invalid response".to_string()))
} else {
Err(ApiError::RequestFailed {
endpoint: url.clone(),
error: format!("{:?}", response.status()),
})
Err(ApiError::SyncFailed("server reject the request".to_string()))
}
}

View file

@ -0,0 +1,199 @@
// SPDX-License-Identifier: MIT
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::cipher::CipherString;
#[derive(Debug, Serialize)]
pub(crate) struct PreloginRequest<'a> {
pub email: &'a str,
}
#[derive(Debug, Deserialize)]
pub(crate) struct PreloginResponse {
#[serde(alias = "Kdf")]
pub kdf: u32,
#[serde(alias = "KdfIterations")]
pub kdf_iterations: u32,
}
#[derive(Debug, Serialize)]
pub(crate) struct LoginRequest<'a> {
pub grant_type: &'a str,
pub username: &'a str,
pub scope: &'a str,
pub client_id: &'a str,
#[serde(alias = "deviceType")]
pub device_type: usize,
#[serde(alias = "deviceIdentifier")]
pub device_id: &'a str,
#[serde(alias = "deviceName")]
pub device_name: &'a str,
pub password: &'a str,
}
#[derive(Debug, Deserialize)]
pub(crate) struct LoginResponse {
pub access_token: String,
pub expires_in: usize,
pub token_type: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Profile {
#[serde(alias = "Object")]
object: String,
#[serde(alias = "Id")]
pub uuid: Uuid,
#[serde(alias = "Name")]
pub name: String,
#[serde(alias = "Email")]
pub email: String,
#[serde(alias = "EmailVerified")]
pub email_verified: bool,
#[serde(alias = "Premium")]
pub premium: bool,
#[serde(alias = "MasterPasswordHint")]
pub master_password_hint: Option<String>,
#[serde(alias = "Culture")]
pub language: String,
#[serde(alias = "TwoFactorEnabled")]
pub tfa_enabled: bool,
#[serde(alias = "Key")]
pub key: CipherString,
#[serde(alias = "PrivateKey")]
pub private_key: CipherString,
#[serde(alias = "SecurityStamp")]
pub security_stamp: String,
#[serde(alias = "Organizations")]
pub organizations: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Folder {
#[serde(alias = "Object")]
object: String,
#[serde(alias = "Id")]
pub uuid: Uuid,
#[serde(alias = "Name")]
pub name: CipherString,
#[serde(alias = "RevisionDate")]
pub last_changed: DateTime<Utc>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CipherEntryFields {
#[serde(alias = "Type")]
pub type_: usize,
#[serde(alias = "Name")]
pub name: CipherString,
#[serde(alias = "Value")]
pub value: CipherString,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CipherEntryHistory {
#[serde(alias = "Password")]
pub password: String,
#[serde(alias = "LastUsedDate")]
pub last_used_date: DateTime<Utc>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CipherEntryUriMatch {
#[serde(alias = "Uri")]
pub uri: CipherString,
#[serde(alias = "Match")]
pub match_: Option<usize>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CipherEntryData {
#[serde(alias = "Uri")]
pub uri: Option<CipherString>,
#[serde(alias = "Uris")]
pub uris: Option<Vec<CipherEntryUriMatch>>,
#[serde(alias = "Username")]
pub username: CipherString,
#[serde(alias = "Password")]
pub password: CipherString,
#[serde(alias = "PasswordRevisionDate")]
pub assword_last_changed: Option<DateTime<Utc>>,
#[serde(alias = "Totp")]
pub totp: Option<String>,
#[serde(alias = "Name")]
pub name: CipherString,
#[serde(alias = "Notes")]
pub notes: Option<String>,
#[serde(alias = "Fields")]
pub fields: Option<Vec<CipherEntryFields>>,
#[serde(alias = "PasswordHistory")]
pub password_history: Option<Vec<CipherEntryHistory>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CipherEntry {
#[serde(alias = "Object")]
object: String,
#[serde(alias = "CollectionIds")]
pub collection_ids: Vec<Uuid>,
#[serde(alias = "FolderId")]
pub folder_id: Option<Uuid>,
#[serde(alias = "Favorite")]
pub favorite: bool,
#[serde(alias = "Edit")]
pub edit: bool,
#[serde(alias = "Id")]
pub uuid: Uuid,
#[serde(alias = "OrganizationId")]
pub organization_id: Option<Uuid>,
#[serde(alias = "Type")]
pub type_: usize,
#[serde(alias = "Data")]
pub data: CipherEntryData,
#[serde(alias = "Name")]
pub name: CipherString,
#[serde(alias = "Notes")]
pub notes: Option<String>,
#[serde(alias = "Login", skip)]
pub login: Option<CipherEntryData>,
#[serde(alias = "Card")]
pub card: Option<String>,
#[serde(alias = "Identity")]
pub identity: Option<String>,
#[serde(alias = "SecureNote")]
pub secure_note: Option<String>,
#[serde(alias = "Fields")]
pub fields: Option<Vec<CipherEntryFields>>,
#[serde(alias = "PasswordHistory")]
pub password_history: Option<Vec<CipherEntryHistory>>,
#[serde(alias = "Attachments")]
pub attachments: Option<String>,
#[serde(alias = "OrganizationUseTotp")]
pub organization_tfa: bool,
#[serde(alias = "RevisionDate")]
pub last_changed: DateTime<Utc>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Domains {
// TODO
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SyncResponse {
#[serde(alias = "Object")]
object: String,
#[serde(alias = "Profile")]
pub profile: Profile,
#[serde(alias = "Folders")]
pub folders: Vec<Folder>,
#[serde(alias = "Collections")]
pub collections: Vec<String>,
#[serde(alias = "Ciphers")]
pub ciphers: Vec<CipherEntry>,
#[serde(alias = "Domains", skip)]
domains: Option<Domains>,
}

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: MIT
pub mod api;
pub mod api_definition;
pub mod cipher;
pub use api::*;
pub use api_definition::*;

View file

@ -1,33 +1,38 @@
// SPDX-License-Identifier: MIT
use std::sync::mpsc;
use std::thread;
use cursive::direction::Orientation;
use cursive::event::Event;
use cursive::traits::*;
use cursive::views::{Dialog, EditView, LinearLayout, OnEventView, TextView};
use cursive::Cursive;
use cursive::views::{Dialog, EditView, LinearLayout, OnEventView, TextContent, TextView};
use cursive::{CbSink as CursiveSink, Cursive};
use bitwarden::cipher::CipherSuite;
use bitwarden::{self, ApiError, AppData, AuthData, VaultData};
use bitwarden::{self, ApiError, AuthData};
use crate::vault;
use crate::vault::{self, VaultData};
pub fn ask(siv: &mut Cursive, default_email: Option<String>) {
let email_edit = EditView::new()
.content(default_email.clone().unwrap_or_else(|| "".to_owned()))
.with_name("email");
enum LoginProgress {
Decrypting,
Syncing,
Finished(VaultData),
Error(Option<VaultData>, ApiError),
}
type LoginProgressSink = mpsc::Sender<LoginProgress>;
type LoginProgressRecv = mpsc::Receiver<LoginProgress>;
pub fn create(siv: &mut Cursive) {
let email_edit = EditView::new().with_name("email");
let email_view = OnEventView::new(email_edit).on_event(Event::CtrlChar('u'), |siv| {
if let Some(mut view) = siv.find_name::<EditView>("email") {
view.set_content("")(siv);
}
siv.find_name::<EditView>("email").unwrap().set_content("")(siv);
});
let password_edit = EditView::new().secret().with_name("master_password");
let password_view = OnEventView::new(password_edit).on_event(Event::CtrlChar('u'), |siv| {
if let Some(mut view) = siv.find_name::<EditView>("master_password") {
view.set_content("")(siv);
}
siv.find_name::<EditView>("master_password").unwrap().set_content("")(siv);
});
let layout = LinearLayout::new(Orientation::Vertical)
@ -36,74 +41,120 @@ pub fn ask(siv: &mut Cursive, default_email: Option<String>) {
.child(TextView::new("master password:"))
.child(password_view);
siv.add_layer(
Dialog::around(layout)
.title("bitwarden vault login")
.button("Ok", |siv| {
let email = siv
.call_on_name("email", |view: &mut EditView| view.get_content())
.unwrap()
.to_string();
let dialog = Dialog::around(layout)
.title("bitwarden vault login")
.button("Ok", on_login)
.min_width(60);
let password = siv
.call_on_name("master_password", |view: &mut EditView| view.get_content())
.unwrap();
siv.clear();
siv.add_layer(dialog);
check_master_password(siv, email, &password);
})
.min_width(60),
);
if default_email.is_some() {
if let Some(email) = siv.user_data().map(|data: &mut VaultData| data.sync.profile.email.clone()) {
siv.find_name::<EditView>("email").unwrap().set_content(email)(siv);
siv.focus_name("master_password").unwrap();
}
}
fn check_master_password(siv: &mut Cursive, email: String, master_password: &str) {
if let Some(app_data) = siv.take_user_data::<AppData>() {
let AppData { mut auth, vault } = app_data;
fn on_login(siv: &mut Cursive) {
let email = siv.find_name::<EditView>("email").unwrap().get_content().to_string();
let password = siv.find_name::<EditView>("master_password").unwrap().get_content().to_string();
auth.cipher = CipherSuite::from(&email, master_password, auth.kdf_iterations);
siv.set_autorefresh(true);
let progress_text = TextContent::new("authenticating ...");
let progress_view = TextView::new_with_content(progress_text.clone());
let progress_dialog = Dialog::around(progress_view).with_name("progress_dialog");
siv.add_layer(progress_dialog);
if auth.cipher.set_decrypt_key(&vault.profile.key).is_err() {
siv.add_layer(Dialog::info("Wrong vault password"));
let vault_data = siv.take_user_data();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
if let Some(data) = vault_data {
decrypt_cached(tx, data, &email, &password);
} else {
vault::show(siv, auth, vault);
sync_and_decrypt(tx, &email, &password);
}
});
return;
}
let cb_sink = siv.cb_sink().clone();
thread::spawn(move || {
process_login(cb_sink, rx, progress_text);
});
}
let auth_data = bitwarden::authenticate(&email, &master_password);
fn decrypt_cached(
sink: LoginProgressSink,
mut vault: VaultData,
email: &str,
master_password: &str
) {
sink.send(LoginProgress::Decrypting).unwrap();
vault.auth.cipher = CipherSuite::from(&email, master_password, vault.auth.kdf_iterations);
match auth_data {
Ok(mut auth_data) => {
siv.pop_layer();
let vault = sync_vault_data(siv, &auth_data).unwrap();
if auth_data.cipher.set_decrypt_key(&vault.profile.key).is_err() {
siv.add_layer(Dialog::info("Wrong vault password"));
} else {
vault::show(siv, auth_data, vault);
}
}
Err(_) => siv.add_layer(Dialog::info("Wrong vault password")),
if vault.auth.cipher.set_decrypt_key(&vault.sync.profile.key).is_err() {
sink.send(
LoginProgress::Error(Some(vault), ApiError::LoginFailed)
).unwrap();
} else {
vault::decrypt(&mut vault);
sink.send(LoginProgress::Finished(vault)).unwrap();
}
}
fn sync_vault_data(siv: &mut Cursive, auth_data: &AuthData) -> Result<VaultData, ApiError> {
match bitwarden::sync(&auth_data) {
Ok(vault_data) => {
if let Err(err) = vault::save_app_data(&auth_data, &vault_data) {
siv.add_layer(Dialog::info(err.to_string()));
}
fn sync_and_decrypt(sink: LoginProgressSink, email: &str, master_password: &str) {
match bitwarden::authenticate(&email, &master_password) {
Ok(auth) => {
sink.send(LoginProgress::Syncing).unwrap();
let vault = sync_vault_data(auth).unwrap();
Ok(vault_data)
}
Err(err) => {
siv.add_layer(Dialog::info(err.to_string()));
Err(err)
}
decrypt_cached(sink, vault, email, master_password);
},
Err(err) => sink.send(LoginProgress::Error(None, err)).unwrap(),
}
}
fn process_login(sink: CursiveSink, recv: LoginProgressRecv, progress_text: TextContent) {
for progress in recv.iter() {
match progress {
LoginProgress::Decrypting => {
progress_text.set_content("decrypting ...");
},
LoginProgress::Syncing => {
progress_text.set_content("syncing ...");
},
LoginProgress::Finished(vault) => {
sink.send(Box::new(|siv| {
siv.set_user_data(vault);
vault::create(siv);
})).unwrap();
break;
},
LoginProgress::Error(vault, err) => {
sink.send(Box::new(move |siv| {
siv.pop_layer();
siv.find_name::<EditView>("master_password").unwrap().set_content("")(siv);
if let Some(vault) = vault {
siv.set_user_data(vault);
}
siv.add_layer(Dialog::info(err.to_string()));
})).unwrap();
break;
},
}
}
sink.send(Box::new(|siv| {
siv.set_autorefresh(false);
})).unwrap();
}
fn sync_vault_data(auth: AuthData) -> Result<VaultData, String> {
bitwarden::sync(&auth)
.map(|sync| VaultData { auth, sync, decrypted: Vec::new() })
.map_err(|e| e.to_string())
.and_then(|vault| vault::save_local_data(&vault).and(Ok(vault)))
}

View file

@ -1,12 +1,12 @@
// SPDX-License-Identifier: MIT
mod login;
mod vault;
use cursive::backends::termion::Backend;
use cursive::Cursive;
use cursive_buffered_backend::BufferedBackend;
mod login;
mod vault;
fn main() {
// We need to use a buffered backend due to flickering with termion.
let mut siv = Cursive::new(|| {
@ -16,13 +16,10 @@ fn main() {
Box::new(buffered)
});
let mut email = None;
if let Ok(data) = vault::read_app_data() {
email = Some(data.vault.profile.email.clone());
siv.set_user_data(data);
if let Ok(vault) = vault::read_local_data() {
siv.set_user_data(vault);
}
login::ask(&mut siv, email);
login::create(&mut siv);
siv.run();
}

View file

@ -18,7 +18,7 @@ use serde::Serialize;
use unicase::UniCase;
use bitwarden::cipher::CipherSuite;
use bitwarden::{ApiError, AppData, AuthData, CipherEntry, VaultData};
use bitwarden::{AuthData, CipherEntry, SyncResponse};
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
enum VaultColumn {
@ -27,18 +27,24 @@ enum VaultColumn {
Username,
}
#[derive(Clone, Debug)]
struct VaultEntry {
#[derive(Clone)]
pub struct VaultEntry {
name: UniCase<String>,
username: UniCase<String>,
password: String,
favorite: String,
}
pub struct VaultData {
pub auth: AuthData,
pub sync: SyncResponse,
pub decrypted: Vec<VaultEntry>,
}
type VaultTableView = TableView<VaultEntry, VaultColumn>;
impl VaultEntry {
fn from_cipher_entry(entry: &CipherEntry, cipher: &CipherSuite) -> Option<VaultEntry> {
pub fn from_cipher_entry(entry: &CipherEntry, cipher: &CipherSuite) -> Option<VaultEntry> {
let favorite = if entry.favorite {
"\u{2605}"
} else {
@ -75,18 +81,12 @@ impl TableViewItem<VaultColumn> for VaultEntry {
}
}
pub fn show(siv: &mut Cursive, auth_data: AuthData, vault_data: VaultData) {
let items = vault_data
.ciphers
.iter()
.map(|c| VaultEntry::from_cipher_entry(&c, &auth_data.cipher).unwrap())
.collect::<Vec<VaultEntry>>();
pub fn create(siv: &mut Cursive) {
let mut table = VaultTableView::new()
.column(VaultColumn::Favorite, "", |c| c.width(1))
.column(VaultColumn::Name, "Name", |c| c.width_percent(25))
.column(VaultColumn::Username, "Username", |c| c)
.items(items.clone());
.items(siv.user_data::<VaultData>().unwrap().decrypted.clone());
table.sort_by(VaultColumn::Name, Ordering::Less);
table.sort_by(VaultColumn::Favorite, Ordering::Less);
@ -154,7 +154,7 @@ pub fn show(siv: &mut Cursive, auth_data: AuthData, vault_data: VaultData) {
let search_field = EditView::new()
.on_edit(move |siv, content, _| {
fuzzy_match_on_edit(siv, &items, content);
fuzzy_match_on_edit(siv, content);
})
.with_name("search_field")
.full_width();
@ -196,27 +196,33 @@ pub fn show(siv: &mut Cursive, auth_data: AuthData, vault_data: VaultData) {
.child(TextView::new("^F: fuzzy-search")),
);
siv.add_layer(layout);
siv.clear();
siv.add_fullscreen_layer(layout);
siv.focus_name("password_table").unwrap();
}
fn fuzzy_match_on_edit(siv: &mut Cursive, items: &[VaultEntry], content: &str) {
pub fn decrypt(vault: &mut VaultData) {
vault.decrypted = vault.sync
.ciphers
.iter()
.map(|c| VaultEntry::from_cipher_entry(&c, &vault.auth.cipher).unwrap())
.collect();
}
fn fuzzy_match_on_edit(siv: &mut Cursive, content: &str) {
let mut table = siv.find_name::<VaultTableView>("password_table").unwrap();
let items = &siv.user_data::<VaultData>().unwrap().decrypted;
// If no search term is present, sort by name and favorite by default
if content.is_empty() {
table.set_items(items.to_vec());
table.set_items(items.clone());
table.sort_by(VaultColumn::Name, Ordering::Less);
table.sort_by(VaultColumn::Favorite, Ordering::Less);
return;
}
let matcher = SkimMatcherV2::default();
let mut items: Vec<(i64, VaultEntry)> = items
.iter()
let mut items: Vec<(i64, VaultEntry)> = items.iter()
.map(|entry| {
(matcher.fuzzy_match(&entry.name, content), entry.clone())
})
@ -227,7 +233,6 @@ fn fuzzy_match_on_edit(siv: &mut Cursive, items: &[VaultEntry], content: &str) {
items.sort_by(|a, b| a.0.cmp(&b.0).reverse());
let items = items.iter().map(|(_, entry)| entry.clone()).collect();
table.set_selected_row(0);
table.set_items(items);
}
@ -246,50 +251,50 @@ fn get_app_data_path() -> Result<PathBuf, String> {
Ok(path)
}
fn save_data_to<T>(filename: &str, data: &T) -> Result<(), ApiError>
fn save_data_to<T>(filename: &str, data: &T) -> Result<(), String>
where
T: Serialize,
{
let mut path = get_app_data_path()
.map_err(ApiError::VaultDataWriteFailed)?;
.map_err(|err| format!("failed to write sync data: {}", err))?;
path.push(filename);
let file = File::create(path)
.map_err(|e| ApiError::VaultDataWriteFailed(e.to_string()))?;
.map_err(|err| format!("failed to write sync data: {}", err))?;
let writer = BufWriter::new(file);
serde_json::to_writer(writer, data)
.map_err(|e| ApiError::VaultDataWriteFailed(e.to_string()))
.map_err(|err| format!("failed to write sync data: {}", err))
}
fn read_data_from<T>(filename: &str) -> Result<T, ApiError>
fn read_data_from<T>(filename: &str) -> Result<T, String>
where
T: DeserializeOwned,
{
let mut path = get_app_data_path()
.map_err(ApiError::VaultDataReadFailed)?;
.map_err(|err| format!("failed to read sync data: {}", err))?;
path.push(filename);
let file = File::open(path)
.map_err(|e| ApiError::VaultDataReadFailed(e.to_string()))?;
.map_err(|err| format!("failed to read sync data: {}", err))?;
let reader = BufReader::new(file);
serde_json::from_reader(reader)
.map_err(|e| ApiError::VaultDataReadFailed(e.to_string()))
.map_err(|err| format!("failed to read sync data: {}", err))
}
pub fn read_app_data() -> Result<AppData, ApiError> {
pub fn read_local_data() -> Result<VaultData, String> {
let auth = read_data_from("auth.json")?;
let vault = read_data_from("vault.json")?;
let sync = read_data_from("vault.json")?;
Ok(AppData { auth, vault })
Ok(VaultData { auth, sync, decrypted: Vec::new() })
}
pub fn save_app_data(auth: &AuthData, vault: &VaultData) -> Result<(), ApiError> {
save_data_to("auth.json", auth)?;
save_data_to("vault.json", vault)?;
pub fn save_local_data(data: &VaultData) -> Result<(), String> {
save_data_to("auth.json", &data.auth)?;
save_data_to("vault.json", &data.sync)?;
Ok(())
}