Initial commit
This commit is contained in:
commit
a00a8fa54d
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
**/*.rs.bk
|
2246
Cargo.lock
generated
Normal file
2246
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
32
Cargo.toml
Normal file
32
Cargo.toml
Normal 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
22
LICENSE
Normal 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
40
README.md
Normal 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
380
src/api.rs
Normal 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
167
src/cipher.rs
Normal 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
113
src/login.rs
Normal 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
39
src/main.rs
Normal 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
154
src/vault.rs
Normal 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);
|
||||
}
|
Loading…
Reference in a new issue