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:
parent
7e2587a7d0
commit
c82ba73eba
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -122,7 +122,7 @@ checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
|
|||
|
||||
[[package]]
|
||||
name = "bitwarden"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.13.0",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "bitwarden"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
authors = ["Christoph Heiss <me@christoph-heiss.me>"]
|
||||
edition = "2018"
|
||||
|
||||
|
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
|
199
bitwarden/src/api_definition.rs
Normal file
199
bitwarden/src/api_definition.rs
Normal 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>,
|
||||
}
|
|
@ -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::*;
|
||||
|
|
185
src/login.rs
185
src/login.rs
|
@ -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)))
|
||||
}
|
||||
|
|
15
src/main.rs
15
src/main.rs
|
@ -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();
|
||||
}
|
||||
|
|
77
src/vault.rs
77
src/vault.rs
|
@ -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(())
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue