Initial commit

This commit is contained in:
Christoph Heiss 2019-12-21 22:47:46 +01:00
commit a00a8fa54d
Signed by: c8h4
GPG key ID: 73D5E7FDEE3DE49A
11 changed files with 3207 additions and 0 deletions

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 8
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
**/*.rs.bk

2246
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

32
Cargo.toml Normal file
View file

@ -0,0 +1,32 @@
[package]
name = "bwtui"
version = "0.1.0"
authors = ["Christoph Heiss <contact@christoph-heiss.at>"]
edition = "2018"
license = "MIT"
categories = ["command-line-utilities"]
readme = "README.md"
repository = "https://github.com/christoph-heiss/bwtui"
description = " terminal-based vault browser for bitwarden"
[dependencies]
aes = "0.3.2"
base64 = "0.11.0"
block-modes = "0.3.3"
chrono = { version = "0.4.10", features = ["serde"] }
clipboard = "0.5.0"
cursive = { version = "0.12.0", features = ["termion-backend"] }
cursive_buffered_backend = "0.3.1"
cursive_table_view = "0.10.0"
directories = "2.0.2"
failure = "0.1.6"
hkdf = "0.8.0"
hmac = "0.7.1"
log = "0.4.8"
pbkdf2 = "0.3.0"
reqwest = "0.9.24"
serde = { version = "1.0.104", features = ["derive"] }
serde_json = "1.0.44"
sha2 = "0.8.0"
uuid = { version = "0.8.1", features = ["v4", "serde"] }
unicase = "2.6.0"

22
LICENSE Normal file
View file

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2019 Christoph Heiss
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

40
README.md Normal file
View file

@ -0,0 +1,40 @@
# bwtui
Small and simple TUI (terminal user interface) for your bitwarden vault.
Currently only supports reading/copying usernames and passwords for items.
## Controls
- general: `<esc>` or `ctrl-c` to exit
- login: `<tab>` to move between email, password and ok button
- vault: `j/k` move up/down, `ctrl-u` copy username, `ctrl-p` copy password
## Installation
Either directly from git using:
```bash
cargo install --git https://github.com/christoph-heiss/bwtui.git
```
or from [crates.io](https://crates.io/crates/bwtui):
```bash
cargo install bwtui
```
## TODO list
`bwtui` still got lots of rough edges:
- [ ] better error handling/propagating
- [ ] configurable shortcuts
- [ ] (optional) clipboard clearing after x seconds
- [ ] (optional) vault locking after x seconds
- [ ] re-sync with bitwarden server
- [ ] domain list support
- [ ] login URI launching
- [ ] card/identity/note support
- [ ] folder support
- [ ] item totp/notes/custom field support
- [ ] support for on-premise servers
- [ ] check some of the crypto stuff (especially hmac stuff)
- [ ] (maybe) editing of vault items

380
src/api.rs Normal file
View file

@ -0,0 +1,380 @@
// SPDX-License-Identifier: MIT
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{BufWriter, BufReader};
use std::path::{PathBuf};
use chrono::{DateTime, Utc};
use uuid::Uuid;
use reqwest::header::{self, HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize};
use crate::cipher::{CipherSuite, CipherString};
const AUTH_URL: &str = "https://identity.bitwarden.com/connect/token";
const BASE_URL: &str = "https://api.bitwarden.com";
#[derive(Debug, failure::Fail)]
pub enum ApiError {
#[fail(display = "prelogin failed: {}", error)]
PreloginFailed {
error: String,
},
#[fail(display = "authentication failed: {}", error)]
LoginFailed {
error: String,
},
#[fail(display = "failed to retrieve {}: {}", endpoint, error)]
RequestFailed {
endpoint: String,
error: String,
},
#[fail(display = "failed to write sync data: {}", error)]
VaultDataWriteFailed {
error: String,
},
#[fail(display = "failed to read sync data: {}", error)]
VaultDataReadFailed {
error: String,
},
}
pub struct AuthData {
access_token: String,
expires_in: usize,
token_type: String,
pub cipher: CipherSuite,
}
#[derive(Debug, Deserialize)]
struct PreloginResponseData {
#[serde(alias = "Kdf")]
kdf: usize,
#[serde(alias = "KdfIterations")]
kdf_iterations: usize,
}
#[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>,
}
fn perform_prelogin(client: &reqwest::Client, email: &str) -> Result<PreloginResponseData, ApiError> {
let url = format!("{}/accounts/prelogin", BASE_URL);
let mut data = HashMap::new();
data.insert("email", email);
let mut response = client.post(&url)
.json(&data)
.send()
.map_err(|e| ApiError::PreloginFailed { error: e.to_string() })?;
if response.status().is_success() {
let data: PreloginResponseData = response
.json()
.map_err(|e| ApiError::PreloginFailed { error: e.to_string() })?;
Ok(data)
} else {
Err(ApiError::PreloginFailed { error: format!("{:?}", response.status()) })
}
}
fn perform_token_auth(client: &reqwest::Client, email: &str, cipher: &CipherSuite)
-> Result<LoginResponseData, ApiError>
{
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 mut response = client.post(AUTH_URL)
.form(&data)
.send()
.map_err(|e| ApiError::LoginFailed { error: e.to_string() })?;
if response.status().is_success() {
let data: LoginResponseData = response
.json()
.map_err(|e| ApiError::LoginFailed { error: e.to_string() })?;
Ok(data)
} else {
Err(ApiError::LoginFailed { error: format!("{:?}", response.status()) })
}
}
pub fn authenticate(email: &str, password: &str) -> Result<AuthData, ApiError> {
let client = reqwest::Client::new();
let PreloginResponseData { kdf, kdf_iterations } =
perform_prelogin(&client, email)?;
let cipher = CipherSuite::from(email, password, kdf, kdf_iterations);
let LoginResponseData { access_token, expires_in, token_type } =
perform_token_auth(&client, email, &cipher)?;
Ok(AuthData {
access_token,
expires_in,
token_type,
cipher,
})
}
pub fn sync(auth_data: &AuthData) -> Result<VaultData, ApiError> {
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(header::AUTHORIZATION, HeaderValue::from_str(&auth_header).unwrap());
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.map_err(map_reqwest_err)?;
let mut response = client.get(&url)
.send()
.map_err(map_reqwest_err)?;
if response.status().is_success() {
let data: VaultData = response
.json()
.map_err(map_reqwest_err)?;
Ok(data)
} else {
Err(ApiError::RequestFailed {
endpoint: url.clone(),
error: format!("{:?}", response.status())
})
}
}
fn get_vault_data_path() -> Result<PathBuf, String> {
let project_dirs = directories::ProjectDirs::from("", "", "bwtui")
.ok_or("could not retrieve data directory path")?;
let target_dir = project_dirs.data_local_dir();
fs::create_dir_all(target_dir)
.map_err(|_| "could not create data directory")?;
let mut path = PathBuf::new();
path.push(target_dir);
path.push("data.json");
Ok(path)
}
pub fn save_vault_data(vault_data: &VaultData) -> Result<(), ApiError> {
let path = get_vault_data_path()
.map_err(|error| ApiError::VaultDataWriteFailed { error })?;
let file = File::create(path)
.map_err(|e| ApiError::VaultDataWriteFailed { error: e.to_string() })?;
let writer = BufWriter::new(file);
serde_json::to_writer(writer, vault_data)
.map_err(|e| ApiError::VaultDataWriteFailed { error: e.to_string() })
}
pub fn read_local_vault_data() -> Result<VaultData, ApiError> {
let path = get_vault_data_path()
.map_err(|error| ApiError::VaultDataReadFailed { error })?;
let file = File::open(path)
.map_err(|e| ApiError::VaultDataReadFailed { error: e.to_string() })?;
let reader = BufReader::new(file);
serde_json::from_reader(reader)
.map_err(|e| ApiError::VaultDataReadFailed { error: e.to_string() })
}

167
src/cipher.rs Normal file
View file

@ -0,0 +1,167 @@
// SPDX-License-Identifier: MIT
use std::fmt;
use aes::Aes256;
use block_modes::{Cbc, BlockMode, block_padding::Pkcs7};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use pbkdf2::pbkdf2;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde::de::Visitor;
use sha2::Sha256;
pub struct CipherSuite {
kdf: usize,
kdf_iterations: usize,
master_key: Vec<u8>,
pub master_key_hash: String,
mac_key: Vec<u8>,
decrypt_key: Option<Vec<u8>>,
}
impl CipherSuite {
pub fn from(email: &str, password: &str, kdf: usize, kdf_iterations: usize) -> Self {
let (master_key, master_key_hash, mac_key) =
derive_master_key(email, password, kdf_iterations);
Self {
kdf,
kdf_iterations,
master_key,
master_key_hash,
mac_key,
decrypt_key: None,
}
}
pub fn set_decrypt_key(&mut self, key: &CipherString) {
let key = key.decrypt_raw(&self.master_key, &self.mac_key).unwrap();
self.decrypt_key = Some(Vec::from(&key[0..32]));
self.mac_key = Vec::from(&key[32..64]);
}
}
fn derive_master_key(email: &str, password: &str, iter_count: usize) -> (Vec<u8>, String, Vec<u8>) {
let mut master_key = vec![0u8; 32];
pbkdf2::<Hmac<Sha256>>(
password.as_bytes(), email.as_bytes(), iter_count, &mut master_key
);
let mut master_key_hash = [0u8; 32];
pbkdf2::<Hmac<Sha256>>(
&master_key, password.as_bytes(), 1, &mut master_key_hash
);
// Expand master key
let hkdf = Hkdf::<Sha256>::from_prk(&master_key).unwrap();
hkdf.expand("enc".as_bytes(), &mut master_key).unwrap();
let mut mac_key = vec![0u8; 32];
hkdf.expand("mac".as_bytes(), &mut mac_key).unwrap();
(master_key, base64::encode(&master_key_hash), mac_key)
}
#[derive(Clone, Debug)]
pub struct CipherString {
type_: usize,
iv: Vec<u8>,
ct: Vec<u8>,
mac: Vec<u8>,
}
impl CipherString {
fn from_str(text: &str) -> Option<CipherString> {
let type_end = text.find('.')?;
let type_ = text[0..type_end].parse::<usize>().ok()?;
let mut parts = text[type_end+1..].split('|');
let iv = base64::decode(parts.next()?).ok()?;
let ct = base64::decode(parts.next()?).ok()?;
let mac = base64::decode(parts.next()?).ok()?;
Some(CipherString { type_, iv, ct, mac })
}
fn as_str(&self) -> String {
format!("{}.{}|{}|{}",
self.type_,
base64::encode(&self.iv),
base64::encode(&self.ct),
base64::encode(&self.mac),
)
}
fn is_valid_mac(&self, mac_key: &[u8]) -> bool {
let mut message = Vec::<u8>::new();
message.extend(&self.iv);
message.extend(&self.ct);
let mut mac = Hmac::<Sha256>::new_varkey(mac_key).unwrap();
mac.input(&message);
mac.verify(&self.mac).is_ok()
}
pub fn decrypt_raw(&self, key: &[u8], mac: &[u8]) -> Option<Vec<u8>> {
assert!(self.type_ == 2 && key.len() == 32 && mac.len() == 32);
if !self.is_valid_mac(mac) {
return None;
}
// Currently only one cipher (type 2) is supported/used by bitwarden:
// pbkdf2/aes-cbc-256/hmac-sha256
let cipher = Cbc::<Aes256, Pkcs7>::new_var(key, &self.iv).ok()?;
cipher.decrypt_vec(&self.ct).ok()
}
pub fn decrypt(&self, cipher: &CipherSuite) -> Option<String> {
self.decrypt_raw(cipher.decrypt_key.as_ref()?, &cipher.mac_key)
.and_then(|s| String::from_utf8(s).ok())
}
}
struct CipherStringVisitor;
impl<'de> Visitor<'de> for CipherStringVisitor {
type Value = CipherString;
fn expecting(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
fmt.write_str("valid cipher string")
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<CipherString, E> {
CipherString::from_str(value)
.ok_or(E::custom("invalid cipher string"))
}
}
impl<'de> Deserialize<'de> for CipherString {
fn deserialize<D>(deserializer: D) -> Result<CipherString, D::Error>
where D: Deserializer<'de>
{
deserializer.deserialize_str(CipherStringVisitor)
}
}
impl Serialize for CipherString {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer
{
serializer.serialize_str(&self.as_str())
}
}

113
src/login.rs Normal file
View file

@ -0,0 +1,113 @@
// SPDX-License-Identifier: MIT
use cursive::Cursive;
use cursive::direction::Orientation;
use cursive::event::Event;
use cursive::traits::*;
use cursive::view::Selector;
use cursive::views::{Dialog, EditView, LinearLayout, OnEventView, TextView};
use crate::{api, vault};
use api::{ApiError, AuthData, VaultData};
pub fn ask(siv: &mut Cursive, default_email: Option<String>) {
let email_edit = EditView::new()
.content(default_email.clone().unwrap_or("".to_owned()))
.with_id("email");
let email_view =
OnEventView::new(email_edit)
.on_event(Event::CtrlChar('u'), |siv| {
siv.call_on_id("email", |view: &mut EditView| {
view.set_content("");
})
.unwrap()
});
let password_edit = EditView::new()
.secret()
.with_id("master_password");
let password_view =
OnEventView::new(password_edit)
.on_event(Event::CtrlChar('u'), |siv| {
siv.call_on_id("master_password", |view: &mut EditView| {
view.set_content("");
})
.unwrap()
});
let layout = LinearLayout::new(Orientation::Vertical)
.child(TextView::new("email address:"))
.child(email_view)
.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_id("email", |view: &mut EditView| {
view.get_content()
})
.unwrap()
.to_string();
let password = siv
.call_on_id("master_password", |view: &mut EditView| {
view.get_content()
})
.unwrap();
check_master_password(siv, email, &password);
})
.min_width(60)
);
if default_email.is_some() {
siv.focus(&Selector::Id("master_password")).unwrap();
}
}
fn check_master_password(siv: &mut Cursive, email: String, master_password: &str) {
let auth_data = api::authenticate(&email, &master_password);
match auth_data {
Ok(mut auth_data) => {
siv.pop_layer();
let vault_data =
if let Some(vault_data) = siv.take_user_data::<VaultData>() {
vault_data
} else {
sync_vault_data(siv, &auth_data).unwrap()
};
auth_data.cipher.set_decrypt_key(&vault_data.profile.key);
vault::show(siv, auth_data, vault_data);
},
Err(_) => {
siv.add_layer(Dialog::info("Wrong vault password"))
},
}
}
fn sync_vault_data(siv: &mut Cursive, auth_data: &AuthData) -> Result<VaultData, ApiError> {
match api::sync(&auth_data) {
Ok(vault_data) => {
if let Err(err) = api::save_vault_data(&vault_data) {
siv.add_layer(Dialog::info(err.to_string()));
}
Ok(vault_data)
},
Err(err) => {
siv.add_layer(Dialog::info(err.to_string()));
Err(err)
}
}
}

39
src/main.rs Normal file
View file

@ -0,0 +1,39 @@
// SPDX-License-Identifier: MIT
use cursive::backend::termion::Backend;
use cursive::Cursive;
use cursive::event::Key;
use cursive_buffered_backend::BufferedBackend;
mod api;
mod cipher;
mod login;
mod vault;
fn main() {
// We need to use a buffered backend due to flickering with termion.
let mut siv = Cursive::new(|| {
let backend = Backend::init().unwrap();
let buffered = BufferedBackend::new(backend);
Box::new(buffered)
});
#[cfg(debug_assertions)]
cursive::logger::init();
#[cfg(debug_assertions)]
siv.add_global_callback(Key::F1, |s| s.toggle_debug_console());
siv.add_global_callback(Key::Esc, |s| s.quit());
let mut email = None;
if let Ok(data) = api::read_local_vault_data() {
email = Some(data.profile.email.clone());
siv.set_user_data(data);
}
login::ask(&mut siv, email);
siv.run();
}

154
src/vault.rs Normal file
View file

@ -0,0 +1,154 @@
// SPDX-License-Identifier: MIT
use std::cmp;
use clipboard::ClipboardProvider;
use clipboard::ClipboardContext;
use cursive::Cursive;
use cursive::direction::Orientation;
use cursive::event::Event;
use cursive::traits::*;
use cursive::views::{Dialog, LinearLayout, OnEventView, TextView};
use cursive_table_view::{TableView, TableViewItem};
use unicase::UniCase;
use crate::api::{self, CipherEntry};
use crate::cipher::CipherSuite;
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
enum VaultColumn {
Favorite,
Name,
Username,
}
#[derive(Clone, Debug)]
struct VaultEntry {
name: UniCase<String>,
username: UniCase<String>,
password: String,
favorite: String,
}
impl VaultEntry {
fn from_cipher_entry(entry: &CipherEntry, cipher: &CipherSuite) -> Option<VaultEntry> {
let favorite = if entry.favorite { "\u{2605}" } else { "\u{2606}" };
Some(Self {
name: UniCase::new(entry.name.decrypt(cipher)?),
username: UniCase::new(entry.data.username.decrypt(cipher)?),
password: entry.data.password.decrypt(cipher)?,
favorite: favorite.to_owned(),
})
}
}
impl TableViewItem<VaultColumn> for VaultEntry {
fn to_column(&self, column: VaultColumn) -> String {
match column {
VaultColumn::Favorite => self.favorite.clone(),
VaultColumn::Name => self.name.to_string(),
VaultColumn::Username => self.username.to_string(),
}
}
fn cmp(&self, other: &Self, column: VaultColumn) -> cmp::Ordering
where Self: Sized,
{
match column {
VaultColumn::Favorite => self.favorite.cmp(&other.favorite),
VaultColumn::Name => self.name.cmp(&other.name),
VaultColumn::Username => self.username.cmp(&other.username),
}
}
}
type VaultTableView = TableView::<VaultEntry, VaultColumn>;
pub fn show(siv: &mut Cursive, auth_data: api::AuthData, vault_data: api::VaultData) {
let items = vault_data.ciphers
.iter()
.map(|c| VaultEntry::from_cipher_entry(&c, &auth_data.cipher).unwrap())
.collect();
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);
table.sort_by(VaultColumn::Name, cmp::Ordering::Less);
table.sort_by(VaultColumn::Favorite, cmp::Ordering::Less);
let view = OnEventView::new(
table
.with_id("password_table")
.min_size((100, 50))
)
.on_event('j', |siv| {
siv.call_on_id("password_table", |view: &mut VaultTableView| {
if let Some(row) = view.row() {
if row < view.len()-1 {
view.set_selected_row(row + 1);
}
}
})
.unwrap()
})
.on_event('k', |siv| {
siv.call_on_id("password_table", |view: &mut VaultTableView| {
if let Some(row) = view.row() {
if row > 0 {
view.set_selected_row(row - 1);
}
}
})
.unwrap()
})
.on_event(Event::CtrlChar('u'), |siv| {
siv.call_on_id("password_table", |view: &mut VaultTableView| {
if let Some(row) = view.item() {
if let Some(entry) = view.borrow_item(row) {
let mut clipboard: ClipboardContext = ClipboardProvider::new()
.unwrap();
clipboard
.set_contents(entry.username.to_string())
.unwrap();
}
}
})
.unwrap()
})
.on_event(Event::CtrlChar('p'), |siv| {
siv.call_on_id("password_table", |view: &mut VaultTableView| {
if let Some(row) = view.item() {
if let Some(entry) = view.borrow_item(row) {
let mut clipboard: ClipboardContext = ClipboardProvider::new()
.unwrap();
clipboard
.set_contents(entry.password.clone())
.unwrap();
}
}
})
.unwrap()
});
let layout = LinearLayout::new(Orientation::Vertical)
.child(
Dialog::around(view)
.title("bitwarden vault")
)
.child(
TextView::new("^U: Copy username ^P: Copy password")
);
siv.add_layer(layout);
}