├── .gitignore ├── sampledb.upm ├── tupm-screenshot.png ├── LICENSE.txt ├── Cargo.toml ├── src ├── upm │ ├── lib.rs │ ├── error.rs │ ├── openssl_extra.rs │ ├── crypto.rs │ ├── backup.rs │ ├── sync.rs │ └── database.rs └── bin │ ├── tupm │ ├── clipboard.rs │ ├── controller.rs │ └── ui.rs │ └── tupm.rs └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /sampledb.upm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simmons/tupm/HEAD/sampledb.upm -------------------------------------------------------------------------------- /tupm-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simmons/tupm/HEAD/tupm-screenshot.png -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 David Simmons 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tupm" 3 | version = "0.1.0" 4 | authors = ["David Simmons "] 5 | license = "MIT/Apache-2.0" 6 | homepage = "https://cafbit.com/post/tupm/" 7 | repository = "https://github.com/simmons/tupm" 8 | keywords = ["password", "UPM", "UI"] 9 | categories = ["command-line-utilities"] 10 | description = "Terminal Universal Password Manager" 11 | readme = "README.md" 12 | 13 | # The "upm" library can be used independently from the tupm application. 14 | # It provides the core support for reading/writing/syncing UPM 15 | # databases. 16 | [lib] 17 | name = "upm" 18 | path = "src/upm/lib.rs" 19 | 20 | [features] 21 | # No features by default 22 | default = [] 23 | # If this feature is enabled, it adds a --test option to load 24 | # the sampledb.upm with a built-in password. This is a convenience for 25 | # development. 26 | test_database = [] 27 | 28 | [dependencies] 29 | dirs = "1.0" 30 | openssl = "0.10.16" 31 | libc = "0.2" 32 | openssl-sys = "0.9.40" 33 | cursive = "0.10" 34 | clap = "2.25.0" 35 | rpassword = "0.4.0" 36 | chrono = "0.4" 37 | rand = "0.3.15" 38 | time = "0.1.38" 39 | base64 = "0.7.0" 40 | reqwest = "0.9" 41 | 42 | # Crates used for testing 43 | [dev-dependencies] 44 | matches = "0.1.6" 45 | -------------------------------------------------------------------------------- /src/upm/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate provides functions for reading, writing, and synchronizing Universal Password 2 | //! Manager (UPM) version 3 databases. This code is meant to interoperate with the format used by 3 | //! [the original UPM Java application](https://github.com/adrian/upm-swing). 4 | //! 5 | //! A terminal-based interface to UPM databases (tupm) is provided as an example application. 6 | 7 | extern crate rand; 8 | extern crate reqwest; 9 | extern crate time; 10 | 11 | #[cfg(test)] 12 | #[macro_use] 13 | extern crate matches; 14 | 15 | pub mod backup; 16 | mod crypto; 17 | pub mod database; 18 | pub mod error; 19 | mod openssl_extra; 20 | pub mod sync; 21 | 22 | /// If this is true, we'll back backups to both the local filesystem and 23 | /// the remote sync server. This is a safeguard against our code 24 | /// clobbering the database. 25 | pub const PARANOID_BACKUPS: bool = true; 26 | 27 | /// Log formatted messages to stderr, but only for debug builds. 28 | #[macro_export] 29 | #[cfg(debug_assertions)] 30 | macro_rules! log( 31 | ($($arg:tt)*) => { { 32 | use std::io::prelude::*; 33 | let r = writeln!(&mut ::std::io::stderr(), $($arg)*); 34 | r.expect("failed printing to stderr"); 35 | } } 36 | ); 37 | 38 | #[macro_export] 39 | #[cfg(not(debug_assertions))] 40 | macro_rules! log( 41 | ($($arg:tt)*) => { { 42 | } } 43 | ); 44 | -------------------------------------------------------------------------------- /src/upm/error.rs: -------------------------------------------------------------------------------- 1 | //! Provide a UpmError enum which can represent all of the errors that may be returned by upm 2 | //! functions. 3 | 4 | extern crate openssl; 5 | 6 | use std::error; 7 | use std::fmt; 8 | use std::io; 9 | use time; 10 | 11 | /// The errors that may be returned by UPM functions are categorized into these enum variants. 12 | #[derive(Debug)] 13 | pub enum UpmError { 14 | ReadUnderrun, 15 | KeyIVGeneration, 16 | AccountParse(Option), 17 | Io(io::Error), 18 | BadMagic, 19 | BadVersion(u8), 20 | Crypto(openssl::error::ErrorStack), 21 | BadPassword, 22 | InvalidFilename, 23 | TimeParseError(time::ParseError), 24 | Sync(String), 25 | NoDatabaseFilename, 26 | NoDatabasePassword, 27 | NoSyncURL, 28 | NoSyncCredentials, 29 | SyncDatabaseNotFound, 30 | Backup(String), 31 | FlatpackOverflow, 32 | DuplicateAccountName(String), 33 | // PathNotUnicode errors are expected to contain the "lossy" version of the path string, with 34 | // invalid sequences converted into replacement characters via Path::to_string_lossy(). 35 | PathNotUnicode(String), 36 | } 37 | 38 | impl fmt::Display for UpmError { 39 | /// Provide human-readable descriptions of the errors. 40 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 41 | match *self { 42 | UpmError::ReadUnderrun => write!(f, "read underrun"), 43 | UpmError::KeyIVGeneration => write!(f, "cannot generate key/iv"), 44 | UpmError::AccountParse(Some(ref s)) => write!(f, "error parsing account: {}", s), 45 | UpmError::AccountParse(None) => write!(f, "error parsing account"), 46 | UpmError::Io(ref e) => write!(f, "IO error: {}", e), 47 | UpmError::BadMagic => write!(f, "Bad magic in file header."), 48 | UpmError::BadVersion(v) => write!(f, "Unsupported database version: {}", v), 49 | UpmError::Crypto(ref e) => write!(f, "Crypto error: {}", e), 50 | UpmError::BadPassword => write!(f, "The provided password is incorrect."), 51 | UpmError::InvalidFilename => write!(f, "The database file path is invalid."), 52 | UpmError::TimeParseError(e) => write!(f, "Time parsing error: {}", e), 53 | UpmError::Sync(ref s) => write!(f, "Sync error: {}", s), 54 | UpmError::NoDatabaseFilename => write!(f, "No database filename was supplied."), 55 | UpmError::NoDatabasePassword => write!(f, "No database password was supplied."), 56 | UpmError::NoSyncURL => write!(f, "No sync URL is configured for this database."), 57 | UpmError::NoSyncCredentials => write!(f, "No sync credentials were supplied."), 58 | UpmError::SyncDatabaseNotFound => write!(f, "The remote database was not present."), 59 | UpmError::Backup(ref s) => write!(f, "Error making backup; not saved: {}", s), 60 | UpmError::FlatpackOverflow => { 61 | write!(f, "Data exceeds flatpack record limit of 9999 bytes.") 62 | } 63 | UpmError::DuplicateAccountName(ref s) => { 64 | write!(f, "Duplicate account name detected: \"{}\"", s) 65 | } 66 | UpmError::PathNotUnicode(ref s) => write!(f, "Path is not valid Unicode: \"{}\".", s), 67 | } 68 | } 69 | } 70 | 71 | impl error::Error for UpmError { 72 | /// Provide terse descriptions of the errors. 73 | fn description(&self) -> &str { 74 | match *self { 75 | UpmError::ReadUnderrun => "read underrun", 76 | UpmError::KeyIVGeneration => "cannot generate key/iv", 77 | UpmError::AccountParse(_) => "cannot parse account", 78 | UpmError::Io(ref err) => error::Error::description(err), 79 | UpmError::BadMagic => "bad magic", 80 | UpmError::BadVersion(_) => "bad database version", 81 | UpmError::Crypto(_) => "OpenSSL error", 82 | UpmError::BadPassword => "bad password", 83 | UpmError::InvalidFilename => "invalid filename", 84 | UpmError::TimeParseError(_) => "time parsing error", 85 | UpmError::Sync(_) => "cannot sync", 86 | UpmError::NoDatabaseFilename => "no database filename", 87 | UpmError::NoDatabasePassword => "no database password", 88 | UpmError::NoSyncURL => "no sync URL", 89 | UpmError::NoSyncCredentials => "no sync credentials", 90 | UpmError::SyncDatabaseNotFound => "remote not found", 91 | UpmError::Backup(_) => "backup error", 92 | UpmError::FlatpackOverflow => "flatpack overflow", 93 | UpmError::DuplicateAccountName(_) => "duplicate account name", 94 | UpmError::PathNotUnicode(_) => "path is not valid unicode", 95 | } 96 | } 97 | /// For errors which encapsulate another error, allow the caller to fetch the contained error. 98 | fn cause(&self) -> Option<&error::Error> { 99 | match *self { 100 | UpmError::Io(ref err) => Some(err), 101 | UpmError::Crypto(ref err) => Some(err), 102 | UpmError::TimeParseError(ref err) => Some(err), 103 | _ => None, 104 | } 105 | } 106 | } 107 | 108 | impl From for UpmError { 109 | fn from(err: io::Error) -> UpmError { 110 | UpmError::Io(err) 111 | } 112 | } 113 | 114 | impl From for UpmError { 115 | fn from(err: openssl::error::ErrorStack) -> UpmError { 116 | UpmError::Crypto(err) 117 | } 118 | } 119 | 120 | impl From for UpmError { 121 | fn from(err: time::ParseError) -> UpmError { 122 | UpmError::TimeParseError(err) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/bin/tupm/clipboard.rs: -------------------------------------------------------------------------------- 1 | //! This module provides several platform-specific means of copying data to the clipboard. 2 | //! 3 | //! On Linux in X11 environments, the `xsel` or `xclip` command (depending on availability) will be 4 | //! used. On Mac OS, the `pbcopy` command will be used. 5 | 6 | extern crate upm; 7 | 8 | use base64; 9 | use std::env; 10 | use std::io; 11 | use std::io::Write; 12 | use std::path::PathBuf; 13 | use std::process; 14 | 15 | /// The environment variable used to store the system path. 16 | const PATH_ENV: &'static str = "PATH"; 17 | /// The environment variable used to store the X11 display. If this environment variable is not 18 | /// set, we assume that we are not running in an X11 environment. 19 | #[cfg(target_os = "linux")] 20 | const DISPLAY_ENV: &'static str = "DISPLAY"; 21 | /// The name of the Mac OS `pbcopy` command used to copy data to the clipboard. 22 | #[cfg(target_os = "macos")] 23 | const PBCOPY_COMMAND: &'static str = "pbcopy"; 24 | /// The name of the X11 `xsel` command used to copy data to the clipboard. 25 | #[cfg(target_os = "linux")] 26 | const XSEL_COMMAND: &'static str = "xsel"; 27 | /// The name of the X11 `xclip` command used to copy data to the clipboard. 28 | #[cfg(target_os = "linux")] 29 | const XCLIP_COMMAND: &'static str = "xclip"; 30 | 31 | /// Attempt to find the specified command on the path. 32 | fn find_in_path(name: &str) -> Option { 33 | env::var_os(PATH_ENV).and_then(|p| { 34 | env::split_paths(&p) 35 | .filter_map(|d| { 36 | let candidate = d.join(&name); 37 | if candidate.is_file() { 38 | Some(candidate) 39 | } else { 40 | None 41 | } 42 | }) 43 | .next() 44 | }) 45 | } 46 | 47 | /// Return the platform-specific external command used to copy data to the clipboard. 48 | #[cfg(target_os = "macos")] 49 | fn clipboard_command() -> Result { 50 | match find_in_path(PBCOPY_COMMAND) { 51 | Some(path) => Ok(process::Command::new(path)), 52 | None => Err("Cannot find pbcopy command in path.".to_string()), 53 | } 54 | } 55 | 56 | /// Return the platform-specific external command used to copy data to the clipboard. 57 | #[cfg(target_os = "linux")] 58 | fn clipboard_command() -> Result { 59 | if env::var_os(DISPLAY_ENV).is_none() { 60 | return Err("Non-X11 environments not supported.".to_string()); 61 | } 62 | 63 | match find_in_path(XSEL_COMMAND) { 64 | Some(path) => { 65 | let mut command = process::Command::new(path); 66 | command.arg("-ib"); 67 | Ok(command) 68 | } 69 | None => match find_in_path(XCLIP_COMMAND) { 70 | Some(path) => { 71 | let mut command = process::Command::new(path); 72 | command.arg("-selection"); 73 | command.arg("clipboard"); 74 | Ok(command) 75 | } 76 | None => Err(format!("Cannot find xsel or xclip command in path.")), 77 | }, 78 | } 79 | } 80 | 81 | /// Return the platform-specific external command used to copy data to the clipboard. 82 | #[cfg(not(any(target_os = "macos", target_os = "linux")))] 83 | fn clipboard_command() -> Result { 84 | Err("Clipboard support not implemented for this operating system.".to_string()) 85 | } 86 | 87 | // Copy to clipboard using xterm-style using xterm-style OSC 52 88 | // escape sequences, as specified in: 89 | // http://invisible-island.net/xterm/ctlseqs/ctlseqs.html 90 | fn clipboard_osc52(text: &str) { 91 | fn is_screen() -> bool { 92 | match env::var("TERM") { 93 | Ok(t) => t.starts_with("screen"), 94 | Err(_) => false, 95 | } 96 | } 97 | 98 | if !is_screen() { 99 | // The simple case: embed a Base64 representation in the OSC 52 100 | // escape sequence. 101 | let data = base64::encode(&text); 102 | print!("\x1B]52;c;{}\x07", data); 103 | io::stdout().flush().unwrap(); 104 | } else { 105 | // If using screen, we require chunking to pass through the data 106 | // to the upper-level terminal emulation. 107 | 108 | // Wrap every 76 characters of Base64 output, same as the Linux 109 | // base64 command. 110 | const WRAP_CHARS: usize = 76; 111 | let data = base64::encode(&text); 112 | let mut pos = 0usize; 113 | let total_length = data.len(); 114 | let mut first: bool = true; 115 | loop { 116 | // Get the next slice 117 | let slice_top = if pos + WRAP_CHARS <= total_length { 118 | pos + WRAP_CHARS 119 | } else { 120 | total_length 121 | }; 122 | let slice = &data[pos..slice_top]; 123 | 124 | // Output the slice 125 | if first { 126 | first = false; 127 | print!("\x1BP\x1B]52;c;{}", slice); 128 | io::stdout().flush().unwrap(); 129 | } else { 130 | print!("\x1B\x5C\x1BP{}", slice); 131 | io::stdout().flush().unwrap(); 132 | } 133 | 134 | pos += WRAP_CHARS; 135 | if pos >= total_length { 136 | break; 137 | } 138 | } 139 | print!("\x07\x1B\\"); 140 | io::stdout().flush().unwrap(); 141 | } 142 | } 143 | 144 | /// Copy the provided string to the clipboard, if possible. 145 | pub fn clipboard_copy(text: &str) -> Result<(), String> { 146 | // Use OSC 52 for clipboard copy, but only if this is enabled via 147 | // the OSC52 environment variable. 148 | if let Ok(_) = env::var("OSC52") { 149 | clipboard_osc52(text); 150 | return Ok(()); 151 | } 152 | 153 | let mut command = match clipboard_command() { 154 | Ok(command) => command, 155 | Err(e) => return Err(e), 156 | }; 157 | 158 | let process = match command 159 | .stdin(process::Stdio::piped()) 160 | .stdout(process::Stdio::null()) 161 | .stderr(process::Stdio::null()) 162 | .spawn() 163 | { 164 | Err(e) => { 165 | return Err(format!("Cannot spawn clipboard copy command: {}", e)); 166 | } 167 | Ok(process) => process, 168 | }; 169 | 170 | match process.stdin.unwrap().write_all(text.as_bytes()) { 171 | Err(e) => Err(format!("Cannot write to clipboard helper: {}", e)), 172 | Ok(_) => Ok(()), 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/upm/openssl_extra.rs: -------------------------------------------------------------------------------- 1 | //! The rust openssl crate does not wrap every possible function that is provided by OpenSSL. We 2 | //! must add our own wrapper for PKCS12_key_gen_uni() so we can perform the key generation 3 | //! algorithm that is required by the UPMv3 format. 4 | 5 | extern crate libc; 6 | extern crate openssl; 7 | extern crate openssl_sys as ffi; 8 | 9 | use self::libc::{c_int, c_uchar}; 10 | 11 | /// An error with lib `ERR_LIB_EVP` indicates the error was returned from an OpenSSL EVP function. 12 | const ERR_LIB_EVP: u8 = 6; 13 | 14 | /// An error with this reason code indicates a decryption failure, which usually means that the 15 | /// provided password was incorrect. 16 | const EVP_R_BAD_DECRYPT: u16 = 100; 17 | 18 | /// Decompose an error code into a 3-tuple containing the library, function, and reason codes. 19 | fn decompose_error_code(code: u32) -> (u8, u16, u16) { 20 | ( 21 | (code >> 24 & 0xFF) as u8, 22 | (code >> 12 & 0xFFF) as u16, 23 | (code & 0xFFF) as u16, 24 | ) 25 | } 26 | 27 | /// Return true if the provided OpenSSL error stack contains any EVP "bad decrypt" error, which 28 | /// usually means that the provided password was incorrect. Unfortunately, the converse is not 29 | /// necessarily the case -- a bad password can sometimes return gibberish plaintext without 30 | /// indicating EVP_R_BAD_DECRYPT. (The UPM format doesn't allow for any sort of authentication or 31 | /// validity checking.) 32 | pub fn is_bad_decrypt(error_stack: &openssl::error::ErrorStack) -> bool { 33 | for e in error_stack.errors() { 34 | let (lib, _, reason) = decompose_error_code(e.code() as u32); 35 | if lib == ERR_LIB_EVP && reason == EVP_R_BAD_DECRYPT { 36 | return true; 37 | } 38 | } 39 | false 40 | } 41 | 42 | extern "C" { 43 | /// This is the OpenSSL C function which performs PKCS#12 key derivation to generate a key or 44 | /// IV based on the provided UCS-2BE password string. 45 | /// 46 | /// Newer versions of OpenSSL have a PKCS12_key_gen_utf8 function that takes a UTF-8 string. 47 | /// That function would have been better to use, but since it was only recently added we can't 48 | /// count on it being available. It's only a thin wrapper around PKCS12_key_gen_uni(), anyway. 49 | pub fn PKCS12_key_gen_uni( 50 | pass: *const c_uchar, 51 | passlen: c_int, 52 | salt: *const c_uchar, 53 | saltlen: c_int, 54 | id: c_int, 55 | iter: c_int, 56 | n: c_int, 57 | out: *mut c_uchar, 58 | md_type: *const ffi::EVP_MD, 59 | ) -> c_int; 60 | } 61 | 62 | /// Convert a UTF-8 encoded string into a UCS-2BE encoding suitable for PKCS12_key_gen_uni(). 63 | /// 64 | /// PKCS#12 wants strings in "BMPString" encoding, which is actually UCS-2BE. (Not "UTF-16" as the 65 | /// OpenSSL comments would lead you to believe.) This only allows for codepoints in the Basic 66 | /// Multilingual Plane. Hopefully nobody is using fancy emojis in their passwords. 67 | fn str_to_bmpstring(text: &str) -> Box<[u8]> { 68 | // Use a boxed slice so the sensitive data can be reliably zeroed later. 69 | // (A Vec may reallocate and leave behind sensitive material.) 70 | let final_length = text.chars().count() * 2 + 2; 71 | let mut bmpstring: Box<[u8]> = vec![0; final_length].into_boxed_slice(); 72 | 73 | let mut index = 0; 74 | for c in text.chars() { 75 | let codepoint = c as u32; 76 | // The upper 16 bits of the codepoint will be discarded. 77 | bmpstring[index] = ((codepoint >> 8) & 0xFF) as u8; 78 | index += 1; 79 | bmpstring[index] = ((codepoint >> 0) & 0xFF) as u8; 80 | index += 1; 81 | } 82 | bmpstring[index] = 0; 83 | index += 1; 84 | bmpstring[index] = 0; 85 | 86 | bmpstring 87 | } 88 | 89 | /// Generate a key or IV using the key derivation function specified in RFC 7292, "PKCS #12: 90 | /// Personal Information Exchange Syntax v1.1", Appendix B, "Deriving Keys and IVs from Passwords 91 | /// and Salt". 92 | pub fn pkcs12_key_gen( 93 | pass: &str, 94 | salt: &[u8], 95 | id: u8, 96 | iter: usize, 97 | key: &mut [u8], 98 | hash: openssl::hash::MessageDigest, 99 | ) -> Result<(), openssl::error::ErrorStack> { 100 | // Convert password to a BMPString 101 | let mut pass = str_to_bmpstring(pass); 102 | 103 | // Proxy to OpenSSL's PKCS12_key_gen_uni(). 104 | let result: c_int; 105 | unsafe { 106 | assert!(pass.len() <= c_int::max_value() as usize); 107 | assert!(salt.len() <= c_int::max_value() as usize); 108 | assert!(key.len() <= c_int::max_value() as usize); 109 | ffi::init(); 110 | result = PKCS12_key_gen_uni( 111 | pass.as_ptr() as *const _, 112 | pass.len() as c_int, 113 | salt.as_ptr(), 114 | salt.len() as c_int, 115 | id as c_int, 116 | iter as c_int, 117 | key.len() as c_int, 118 | key.as_mut_ptr(), 119 | hash.as_ptr(), 120 | ); 121 | } 122 | 123 | // Zero the encoded bmpstring. 124 | // This may need to be revisited -- will the compiler optimize this out? 125 | // Best practices for sensitive material in Rust are still evolving. 126 | for i in 0..pass.len() { 127 | pass[i] = 0; 128 | } 129 | 130 | if result <= 0 { 131 | Err(openssl::error::ErrorStack::get()) 132 | } else { 133 | Ok(()) 134 | } 135 | } 136 | 137 | #[cfg(test)] 138 | mod tests { 139 | use super::*; 140 | 141 | #[test] 142 | fn test_decompose_error_code() { 143 | assert_eq!( 144 | decompose_error_code(0x06000064), 145 | (ERR_LIB_EVP, 0, EVP_R_BAD_DECRYPT,) 146 | ); 147 | assert_eq!(decompose_error_code(0x12345678), (0x12, 0x345, 0x678)); 148 | assert_eq!(decompose_error_code(0x00000000), (0x00, 0x000, 0x000)); 149 | assert_eq!(decompose_error_code(0xFFFFFFFF), (0xFF, 0xFFF, 0xFFF)); 150 | } 151 | 152 | const HELLOWORLD_STR: &str = "hello world"; 153 | #[cfg_attr(rustfmt, rustfmt_skip)] 154 | const FANCY_UTF8: &[u8] = &[ 155 | 0xCE, 0xB3, 0xCE, 0xBB, 0xCF, 0x8E, 0xCF, 0x83, 156 | 0xCF, 0x83, 0xCE, 0xB1 157 | ]; 158 | const EMPTY_BMPSTRING: &[u8] = &[0x00, 0x00]; 159 | #[cfg_attr(rustfmt, rustfmt_skip)] 160 | const HELLOWORLD_BMPSTRING: &[u8] = &[ 161 | 0x00, 0x68, 0x00, 0x65, 0x00, 0x6C, 0x00, 0x6C, 162 | 0x00, 0x6F, 0x00, 0x20, 0x00, 0x77, 0x00, 0x6F, 163 | 0x00, 0x72, 0x00, 0x6C, 0x00, 0x64, 0x00, 0x00 164 | ]; 165 | #[cfg_attr(rustfmt, rustfmt_skip)] 166 | const FANCY_BMPSTRING: &[u8] = &[ 167 | 0x03, 0xB3, 0x03, 0xBB, 0x03, 0xCE, 0x03, 0xC3, 168 | 0x03, 0xC3, 0x03, 0xB1, 0x00, 0x00 169 | ]; 170 | 171 | #[test] 172 | fn test_str_to_bmpstring() { 173 | use std::str; 174 | assert_eq!(&*str_to_bmpstring(""), EMPTY_BMPSTRING); 175 | assert_eq!(&*str_to_bmpstring(HELLOWORLD_STR), HELLOWORLD_BMPSTRING); 176 | assert_eq!( 177 | &*str_to_bmpstring(str::from_utf8(FANCY_UTF8).unwrap()), 178 | FANCY_BMPSTRING 179 | ); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/upm/crypto.rs: -------------------------------------------------------------------------------- 1 | //! Perform encryption and decryption operations to support the ciphertext component of UPMv3 2 | //! database files. 3 | //! 4 | //! This module implements the following by way of OpenSSL: 5 | //! 6 | //! 1. The UPMv3 key derivation function (KDF) to convert a password into a private key. 7 | //! 2. Encryption. 8 | //! 3. Decryption. 9 | //! 10 | //! UPM encrypts databases using an AES 256-bit cipher in CBC mode. The private key is derived 11 | //! from a password using a PKCS#12 key derivation function (KDF) as specified in RFC 7292 Appendix 12 | //! B, using 20 iterations. This KDF is likely the weakest point of UPM's crypto for the following 13 | //! reasons: 14 | //! 15 | //! 1. This algorithm has been deprecated and is not recommended for new use. 16 | //! 2. An iteration count of 20 likely falls short of the ideal by a couple orders of magnitude. 17 | //! 18 | //! Nonetheless, use of this KDF is required to interoperate with UPMv3 databases. 19 | //! 20 | 21 | extern crate openssl; 22 | 23 | use error::UpmError; 24 | use openssl_extra; 25 | 26 | const KEY_MATERIAL_ID: u8 = 1; 27 | const IV_MATERIAL_ID: u8 = 2; 28 | const KEY_MATERIAL_BITS: usize = 256; 29 | const IV_MATERIAL_BITS: usize = 128; 30 | const KEY_MATERIAL_SIZE: usize = KEY_MATERIAL_BITS / 8; 31 | const IV_MATERIAL_SIZE: usize = IV_MATERIAL_BITS / 8; 32 | const KEY_DERIVATION_ITERATIONS: usize = 20; 33 | 34 | /// This KeyIVPair struct is to arrange zeroing of the key and IV buffers when they go out of 35 | /// scope. Note that the current zeroing method is probably naive, and may not survive compiler 36 | /// optimization. The best practices in Rust for storing sensitive material are still being worked 37 | /// out. 38 | /// 39 | /// The following GitHub issue is informative: 40 | /// 41 | /// * https://github.com/isislovecruft/curve25519-dalek/issues/11 42 | /// 43 | /// Note that there is more sensitive data than just the key/IV. In particular, the following 44 | /// items are sensitive and we need to develop a post-zeroing solution for them: 45 | /// 46 | /// 1. The master password. 47 | /// 2. The account records, including their respective managed passwords. 48 | /// 3. Any intermediate data buffers used to pass these items around. 49 | /// 50 | /// We should probably consider using one of these tools: 51 | /// 52 | /// * https://github.com/cesarb/clear_on_drop 53 | /// * https://github.com/ticki/secbox 54 | /// * https://github.com/stouset/secrets 55 | /// * https://github.com/myfreeweb/secstr 56 | struct KeyIVPair { 57 | pub key: [u8; KEY_MATERIAL_SIZE], 58 | pub iv: [u8; IV_MATERIAL_SIZE], 59 | } 60 | 61 | impl Drop for KeyIVPair { 62 | fn drop(&mut self) { 63 | for i in 0..self.key.len() { 64 | self.key[i] = 0; 65 | } 66 | for i in 0..self.iv.len() { 67 | self.iv[i] = 0; 68 | } 69 | } 70 | } 71 | 72 | impl KeyIVPair { 73 | pub fn new() -> KeyIVPair { 74 | KeyIVPair { 75 | key: [0u8; KEY_MATERIAL_SIZE], 76 | iv: [0u8; IV_MATERIAL_SIZE], 77 | } 78 | } 79 | } 80 | 81 | /// Perform key and IV generation based on the algorithm specified here: 82 | /// 83 | /// * RFC 7292: PKCS #12: Personal Information Exchange Syntax v1.1 Appendix B. Deriving Keys and 84 | /// IVs from Passwords and Salt 85 | /// 86 | /// Note that this is probably the weak point of UPM crypto for the reasons mentioned above. 87 | fn pkcs12_derive_key(password: &str, salt: &[u8], pair: &mut KeyIVPair) -> Result<(), UpmError> { 88 | match openssl_extra::pkcs12_key_gen( 89 | password, 90 | &salt, 91 | KEY_MATERIAL_ID, 92 | KEY_DERIVATION_ITERATIONS, 93 | &mut pair.key, 94 | openssl::hash::MessageDigest::sha256(), 95 | ) { 96 | Ok(()) => {} 97 | Err(_) => { 98 | return Err(UpmError::KeyIVGeneration); 99 | } 100 | }; 101 | match openssl_extra::pkcs12_key_gen( 102 | password, 103 | &salt, 104 | IV_MATERIAL_ID, 105 | KEY_DERIVATION_ITERATIONS, 106 | &mut pair.iv, 107 | openssl::hash::MessageDigest::sha256(), 108 | ) { 109 | Ok(()) => {} 110 | Err(_) => { 111 | return Err(UpmError::KeyIVGeneration); 112 | } 113 | }; 114 | Ok(()) 115 | } 116 | 117 | /// Decrypt the UPMv3 database ciphertext using the provided password and salt. 118 | pub fn decrypt(ciphertext: &[u8], password: &str, salt: &[u8]) -> Result, UpmError> { 119 | let mut pair = KeyIVPair::new(); 120 | try!(pkcs12_derive_key(password, salt, &mut pair)); 121 | 122 | match openssl::symm::decrypt( 123 | openssl::symm::Cipher::aes_256_cbc(), 124 | &pair.key[..], 125 | Option::Some(&pair.iv[..]), 126 | &ciphertext[..], 127 | ) { 128 | Ok(x) => Ok(x), 129 | Err(error_stack) => { 130 | if openssl_extra::is_bad_decrypt(&error_stack) { 131 | Err(UpmError::BadPassword) 132 | } else { 133 | Err(From::from(error_stack)) 134 | } 135 | } 136 | } 137 | } 138 | 139 | /// Encrypt the UPMv3 database plaintext using the provided password and salt. 140 | pub fn encrypt(plaintext: &[u8], password: &str, salt: &[u8]) -> Result, UpmError> { 141 | let mut pair = KeyIVPair::new(); 142 | try!(pkcs12_derive_key(password, salt, &mut pair)); 143 | 144 | match openssl::symm::encrypt( 145 | openssl::symm::Cipher::aes_256_cbc(), 146 | &pair.key[..], 147 | Option::Some(&pair.iv[..]), 148 | &plaintext[..], 149 | ) { 150 | Ok(x) => Ok(x), 151 | Err(error_stack) => { 152 | if openssl_extra::is_bad_decrypt(&error_stack) { 153 | Err(UpmError::BadPassword) 154 | } else { 155 | Err(From::from(error_stack)) 156 | } 157 | } 158 | } 159 | } 160 | 161 | #[cfg(test)] 162 | mod tests { 163 | use super::*; 164 | 165 | const PASSWORD: &str = "xyzzy"; 166 | const SALT: &[u8] = &[0x35, 0xB3, 0x66, 0xE2, 0xF5, 0x28, 0xBF, 0x3E]; 167 | #[cfg_attr(rustfmt, rustfmt_skip)] 168 | const EXPECTED_KEY: &[u8] = &[ 169 | 0x8D, 0x17, 0x3A, 0x33, 0x4D, 0xE4, 0xD4, 0x1F, 170 | 0x75, 0x6A, 0x3C, 0xEB, 0x74, 0xE0, 0x9E, 0xC4, 171 | 0xEC, 0x8F, 0xD3, 0x83, 0x3F, 0x15, 0xAF, 0x86, 172 | 0x54, 0xFE, 0x77, 0x37, 0x32, 0x9E, 0x50, 0x10, 173 | ]; 174 | #[cfg_attr(rustfmt, rustfmt_skip)] 175 | const EXPECTED_IV: &[u8] = &[ 176 | 0x37, 0x26, 0x45, 0x5F, 0xA5, 0x33, 0x0D, 0xD1, 177 | 0x53, 0x78, 0x3A, 0x75, 0x56, 0xB9, 0x34, 0xE3, 178 | ]; 179 | #[cfg_attr(rustfmt, rustfmt_skip)] 180 | const CIPHERTEXT: &[u8] = &[ 181 | 0x0E, 0xF5, 0x4D, 0xD8, 0x47, 0x6B, 0xC2, 0x4E, 182 | 0xA0, 0xA0, 0x47, 0x02, 0x20, 0x25, 0xD8, 0xDB, 183 | 0x01, 0x41, 0xB2, 0x06, 0xE2, 0xB1, 0x50, 0x93, 184 | 0xC1, 0x26, 0x01, 0xE9, 0xA0, 0x96, 0xFA, 0xC7, 185 | 0x0B, 0xE7, 0x80, 0x4F, 0x05, 0x4E, 0xE7, 0x76, 186 | 0x4F, 0xC3, 0x42, 0xAC, 0x76, 0x81, 0x27, 0x8B, 187 | ]; 188 | #[cfg_attr(rustfmt, rustfmt_skip)] 189 | const PLAINTEXT: &[u8] = &[ 190 | 0x30, 0x30, 0x30, 0x31, 0x31, 0x30, 0x30, 0x30, 191 | 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 192 | 0x34, 0x61, 0x63, 0x63, 0x74, 0x30, 0x30, 0x30, 193 | 0x34, 0x75, 0x73, 0x65, 0x72, 0x30, 0x30, 0x30, 194 | 0x34, 0x70, 0x61, 0x73, 0x73, 0x30, 0x30, 0x30, 195 | 0x30, 0x30, 0x30, 0x30, 0x30, 196 | ]; 197 | 198 | #[test] 199 | fn test_pkcs12_derive_key() { 200 | let mut pair = KeyIVPair::new(); 201 | let result = pkcs12_derive_key(PASSWORD, SALT, &mut pair); 202 | assert_matches!(result, Ok(_)); 203 | assert_eq!(pair.key, EXPECTED_KEY); 204 | assert_eq!(pair.iv, EXPECTED_IV); 205 | } 206 | 207 | #[test] 208 | fn test_decrypt() { 209 | let result = decrypt(CIPHERTEXT, PASSWORD, SALT); 210 | assert_matches!(result, Ok(_)); 211 | assert_eq!(result.unwrap().as_slice(), PLAINTEXT); 212 | } 213 | 214 | #[test] 215 | fn test_encrypt() { 216 | let result = encrypt(PLAINTEXT, PASSWORD, SALT); 217 | assert_matches!(result, Ok(_)); 218 | assert_eq!(result.unwrap().as_slice(), CIPHERTEXT); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/upm/backup.rs: -------------------------------------------------------------------------------- 1 | //! Support for making backups of database file. 2 | //! 3 | //! Because `tupm` is experimental code, we are fairly paranoid about making backups of the 4 | //! database early and often -- perhaps even to the point of annoyance to anyone wondering why 5 | //! their UPM directory is littered with all these files. Backup databases are suffixed with a 6 | //! timestamp and a `.bak` extension. Backups are made in the following scenarios: 7 | //! 8 | //! 1. Up to 30 backups of the pre-existing local database are made whenever the database is saved. 9 | //! If 30 backups are already present, the oldest is deleted to make room for a new one. 10 | //! 2. When a sync operation is about to overwrite a remote database with a new revision, it first 11 | //! uploads a backup file of the new revision. If the upload of this backup file fails, the 12 | //! pre-existing remote database is not deleted and an error is presented to the user. This is 13 | //! particularly useful since syncing a new revision consists of non-atomic steps: a "delete" 14 | //! operation followed by an "upload" operation. If the "delete" succeeds but the "upload" 15 | //! fails, the remote database would be lost forever in the absence of backups. There is 16 | //! currently no limit on the number of backups stored on the remote server. 17 | //! 18 | 19 | use error::UpmError; 20 | use std::fs; 21 | use std::path::{Path, PathBuf}; 22 | use std::time::SystemTime; 23 | use time; 24 | 25 | /// The maximum number of backups allowed for the local database. Old backups will be pruned to 26 | /// keep the number of backups within this limit. 27 | const MAX_BACKUP_FILES: usize = 30; 28 | 29 | /// Use this filename extension for backup files. 30 | const BACKUP_FILE_EXTENSION: &'static str = ".bak"; 31 | 32 | /// Remove the oldest backup files as needed to bring the total number of backup files for this 33 | /// path within the limit. 34 | fn prune_old_backups(path: &Path) -> Result { 35 | // What is the backup file prefix? 36 | let prefix = if let Some(s) = path.file_name() { 37 | match s.to_str() { 38 | Some(s) => { 39 | let mut s = String::from(s); 40 | s.push('.'); 41 | s 42 | } 43 | None => return Err(UpmError::InvalidFilename), 44 | } 45 | } else { 46 | return Err(UpmError::InvalidFilename); 47 | }; 48 | 49 | // Build a list of matching files and their modification times 50 | let mut entries = Vec::<(Box, SystemTime)>::new(); 51 | for entry in path.canonicalize()?.parent().unwrap().read_dir()? { 52 | let entry = entry?; 53 | if let Ok(name) = entry.file_name().into_string() { 54 | if name.starts_with(&prefix) && name.ends_with(BACKUP_FILE_EXTENSION) { 55 | let mtime = entry.metadata().unwrap().modified().unwrap(); 56 | entries.push((Box::new(entry.path()), mtime)); 57 | } 58 | } 59 | } 60 | 61 | // If too many backup files are present, delete the oldest one(s) 62 | // to bring us within the limit. 63 | let mut deletion_count = 0; 64 | if entries.len() > MAX_BACKUP_FILES { 65 | entries.sort_by(|a, b| a.1.cmp(&b.1)); 66 | for i in 0..(entries.len() - MAX_BACKUP_FILES) { 67 | fs::remove_file(entries[i].0.as_path())?; 68 | deletion_count += 1; 69 | } 70 | } 71 | Ok(deletion_count) 72 | } 73 | 74 | /// Generate a backup filename for the specified path by appending a timestamp and `.bak` 75 | /// extension. 76 | pub fn generate_backup_filename>(path: P) -> Result { 77 | let basename = if let Some(s) = path.as_ref().file_name() { 78 | match s.to_str() { 79 | Some(x) => x, 80 | None => return Err(UpmError::InvalidFilename), 81 | } 82 | } else { 83 | return Err(UpmError::InvalidFilename); 84 | }; 85 | let current_time = time::now(); 86 | let timestamp = match current_time.strftime("%Y%m%d%H%M%S") { 87 | Ok(t) => t, 88 | Err(e) => { 89 | return Err(UpmError::TimeParseError(e)); 90 | } 91 | }; 92 | let backup_basename = format!("{}.{}{}", basename, timestamp, BACKUP_FILE_EXTENSION); 93 | Ok(path.as_ref().to_path_buf().with_file_name(backup_basename)) 94 | } 95 | 96 | /// If the file at the specified path exists, make a backup, and remove any old backup files as 97 | /// needed to bring the total number of backup files for this path within the limit. `Ok(true)` is 98 | /// returned on success, otherwise an error is returned. 99 | /// 100 | /// If the file does not exist, this is not considered an error since it merely means that no 101 | /// backup is needed. In this case, `Ok(false)` is returned. 102 | pub fn backup(path: &Path) -> Result { 103 | if !path.exists() { 104 | // Nothing to backup. 105 | return Ok(false); 106 | } 107 | 108 | // Generate the backup filename 109 | let backup_path = generate_backup_filename(path)?; 110 | 111 | // Make the backup file 112 | fs::copy(path, backup_path)?; 113 | 114 | // Prune old backups 115 | // (Ignore errors -- this is best-effort-only.) 116 | prune_old_backups(path).unwrap_or_default(); 117 | 118 | Ok(true) 119 | } 120 | 121 | #[cfg(test)] 122 | mod tests { 123 | use super::*; 124 | 125 | /// A character-oriented substring function used by our test. 126 | fn substring(original: &str, position: usize, length: usize) -> &str { 127 | let start_pos = original 128 | .char_indices() 129 | .nth(position) 130 | .map(|(n, _)| n) 131 | .unwrap_or(0); 132 | let end_pos = original 133 | .char_indices() 134 | .nth(position + length) 135 | .map(|(n, _)| n) 136 | .unwrap_or(0); 137 | &original[start_pos..end_pos] 138 | } 139 | 140 | /// Test the generate_backup_filename() function. 141 | #[test] 142 | fn test_generate_backup_filename() { 143 | // Confirm that a bad path returns an error. 144 | assert_matches!(generate_backup_filename(""), Err(UpmError::InvalidFilename)); 145 | 146 | // Confirm that the basic structure of the returned backup path is correct. 147 | const TEST_PATH: &'static str = "/path/to/file"; 148 | const TIMESTAMP_LENGTH: usize = 14; 149 | const DECIMAL_RADIX: u32 = 10; 150 | let test_path_length = TEST_PATH.chars().count(); 151 | let expected_length = 152 | test_path_length + 1 + TIMESTAMP_LENGTH + BACKUP_FILE_EXTENSION.chars().count(); 153 | let backup_time = time::now(); 154 | let backup_filename = generate_backup_filename(TEST_PATH); 155 | assert_matches!(backup_filename, Ok(_)); 156 | let backup_filename = backup_filename.unwrap(); 157 | let backup_filename = backup_filename.to_string_lossy(); 158 | assert!(backup_filename.starts_with(TEST_PATH)); 159 | assert!(backup_filename.ends_with(BACKUP_FILE_EXTENSION)); 160 | assert_eq!(backup_filename.chars().count(), expected_length); 161 | assert_matches!( 162 | backup_filename.chars().nth(TEST_PATH.chars().count()), 163 | Some('.') 164 | ); 165 | 166 | // Confirm that the timestamp is correctly rendered to represent the time we asked for the 167 | // backup filename, +/- 10 seconds. 168 | const ALLOWED_TIMESTAMP_VARIANCE_SECS: i64 = 10; 169 | let timestamp = substring(&backup_filename, test_path_length + 1, TIMESTAMP_LENGTH); 170 | assert!(timestamp.chars().all(|c| c.is_digit(DECIMAL_RADIX))); 171 | let timestamp_time = time::strptime(timestamp, "%Y%m%d%H%M%S"); 172 | assert_matches!(timestamp_time, Ok(_)); 173 | let mut timestamp_time = timestamp_time.unwrap(); 174 | // The timestamp is parsed as UTC, so force it to be in the correct zone/DST configuration. 175 | timestamp_time.tm_utcoff = backup_time.tm_utcoff; 176 | timestamp_time.tm_isdst = backup_time.tm_isdst; 177 | // Confirm that the timestamp roughly represents the expected time. 178 | let difference = timestamp_time.to_utc() - backup_time.to_utc(); 179 | assert!(difference < time::Duration::seconds(ALLOWED_TIMESTAMP_VARIANCE_SECS)); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Terminal Universal Password Manager 3 | ======================================== 4 | 5 | The Terminal Universal Password Manager (`tupm`) is a terminal-based 6 | interface to password manager databases produced by the 7 | [Universal Password Manager (UPM)](http://upm.sourceforge.net/) project. 8 | This is a proof-of-concept exercise, and should not be used in 9 | production to protect real secrets. 10 | 11 | Important disclaimers: 12 | 13 | 1. **This program may not be secure or reliable.** It may corrupt your 14 | password database, expose your secrets, and keep your cereal from 15 | staying crunchy in milk. You should absolutely read the "Risks" 16 | section, below, before even thinking about using it. Consider 17 | yourself warned. 18 | 2. This code is an independent re-implementation of UPM, and is not 19 | related to the original UPM program or its author in any way other 20 | than its ability to read and write UPM databases and speak the 21 | HTTP-based UPM sync protocol. 22 | 3. This program was written as a learning exercise to gain experience 23 | with the process of developing Rust crates from "hello world" to 24 | "cargo publish". The contained library providing access to UPM 25 | databases may be of some use for developers looking to import and 26 | export, but the program as a whole should be considered more of a 27 | proof-of-concept rather than production-ready software. 28 | 29 | Tupm is dual-licensed under MIT or Apache 2.0, the same as Rust itself. 30 | 31 | Screenshot 32 | -------------------- 33 | 34 | ![Screenshot](tupm-screenshot.png) 35 | 36 | Installation 37 | -------------------- 38 | 39 | Tupm may be downloaded and installed via the Rust `cargo` command: 40 | 41 | ``` 42 | $ cargo install tupm 43 | ``` 44 | 45 | Usage 46 | -------------------- 47 | 48 | ``` 49 | Terminal Universal Password Manager 0.1.0 50 | Provides a terminal interface to Universal Password Manager (UPM) databases. 51 | 52 | USAGE: 53 | tupm [FLAGS] [OPTIONS] 54 | 55 | FLAGS: 56 | -e, --export Export database to a flat text file. 57 | -h, --help Prints help information 58 | -p, --password Prompt for a password. 59 | -V, --version Prints version information 60 | 61 | OPTIONS: 62 | -d, --database Specify the path to the database. 63 | -l, --download Download a remote database. 64 | ``` 65 | 66 | Running `tupm` with no arguments will load the database present in 67 | `$HOME/.tupm/primary` or create a new database if one does not already 68 | exist. A different database path may be specified with the `--database` 69 | option. 70 | 71 | Alternately, a database can be imported from an existing UPM sync 72 | repository with the `--download` option. (Only HTTP/HTTPS based 73 | repositories are supported. The option to use Dropbox is not 74 | supported.) The repository URL (without the database name appended) 75 | should be provided. By default, a database named "primary" is 76 | downloaded and installed into `$HOME/.tupm/primary`, unless an alternate 77 | database path was specified with `--database`. You will be prompted for 78 | the HTTP username and password credentials: 79 | 80 | ``` 81 | $ tupm --download https://example.edu/repo/ 82 | Downloading remote database "primary" from repository "https://example.edu/repo/". 83 | Repository username: username 84 | Repository password: 85 | 23708 bytes downloaded from repository. 86 | Database written to: /home/username/.tupm/primary. 87 | ``` 88 | 89 | After a database is loaded, you are presented with a user interface 90 | showing a navigable list of accounts, detailed information about the 91 | selected account, a filter box (quickly accessible by pressing `/`), and 92 | a menu of options accessible by pressing escape or `\`. Most menu 93 | options have keyboard shortcuts for direct invocation. 94 | 95 | For the exceptionally brave among you, the `--export` command-line 96 | argument will write a full plaintext report of the contents of the 97 | database to standard output. (It goes without saying that such exported 98 | data is not at all protected by encryption and thus highly vulnerable.) 99 | 100 | Risks 101 | -------------------- 102 | 103 | I wrote Tupm as a Rust learning exercise. I'm making it available on 104 | the off chance that other developers might find its code useful when 105 | interoperating with UPM databases. However, for several reasons, I'd 106 | like to discourage anyone from using it directly to manage passwords. 107 | 108 | ### Cryptography concerns with the UPM format 109 | 110 | Work on UPM goes back to 2005, and its usage of cryptography would 111 | probably be considered less than ideal by 2017 standards. I'm not a 112 | cryptographer, but I do have several concerns about the cryptography 113 | used in the UPM format. 114 | 115 | 1. **Usage of PKCS#12 key derivation.** UPM derives a key from the 116 | master password using the PKCS#12 v1.0 key derivation function (KDF). 117 | The latest PKCS#12 standard declares that this KDF "is not 118 | recommended and is deprecated for new usage" and recommends using 119 | PBKDF2 instead. (See: [RFC 7292 Appendix B] 120 | (https://tools.ietf.org/html/rfc7292#appendix-B).) The PKCS#12 121 | KDF does not seem to be commonly used outside of protecting actual 122 | PKCS#12 data structures. I'm not aware of any specific weaknesses 123 | that have been found, but its deprecation status doesn't inspire much 124 | confidence. 125 | 2. **Insufficient KDF iterations.** The effectiveness provided by a KDF 126 | such as the one specified by PKCS#12 is related to the number of 127 | iterations performed. The original PKCS#12 v1.0 document from 1996 128 | used 1024 as an example iteration count, but this was later 129 | considered to be insufficient. The count should be as high as 130 | possible to increase the cost of brute-force attacks without causing 131 | a noticeable delay for the user. My underpowered Asus C201P 132 | ARM-based Chromebook can perform 1.5 million iterations/sec of 133 | PKCS#12 KDF, so iterations can be quite high. The UPM format uses an 134 | iteration count of 20. 135 | 3. **Unauthenticated storage.** The UPM cryptosystem does not provide 136 | any authenticity of the ciphertext, so an attacker could 137 | theoretically modify bits of the ciphertext to cause corruption, 138 | solicit unintended behavior from the parsing program, or possibly 139 | even yield different but seemingly valid decrypted plaintext. 140 | Any future revision of the format should calculate a MAC after 141 | encryption and include it in the database format, or ideally simply 142 | use an authenticated encryption mode such as GCM. 143 | 144 | ### In-memory handling of sensitive material 145 | 146 | While Tupm is running, sensitive materials such as the master password, 147 | derived keys, and the stored account passwords are stored in memory in 148 | the clear, with little or no provision for erasing them when they are no 149 | longer needed. This is okay for a proof-of-concept demonstration, but 150 | would definitely be **not good** for a production password manager. 151 | 152 | Developing a set of best practices for handling such material in a 153 | cross-platform application would be a great research project in and of 154 | itself, and probably consider steps such as: 155 | 1. Zero-on-drop data structures that avoid the possibility of the compiler 156 | optimizing away the erasing. Rust's lack of immovable types may also 157 | be an issue. 158 | 2. OS-specific features for each platform, such as `mlock()`/`munlock()` 159 | to prevent sensitive data from being swapped to disk, and 160 | `mprotect()` to prevent such data from being saved with core dumps. 161 | 3. Perhaps it will eventually be practical to make use of certain 162 | hardware-specific protection features where available, such as Intel 163 | Software Guard Extensions (SGX). 164 | 165 | In addition to the memory of the Tupm program itself, sensitive 166 | information could leak through adjacent programs. For example, 167 | passwords shown in the terminal may remain in the scrollback buffer, and 168 | passwords copied to the system clipboard remain there until overwritten. 169 | 170 | Thanks 171 | -------------------- 172 | 173 | Thanks to Adrian Smith for developing the original UPM programs, 174 | Alexandre Bury for the Cursive library used to provide the 175 | terminal-based user interface, and the greater Rust community. 176 | -------------------------------------------------------------------------------- /src/bin/tupm.rs: -------------------------------------------------------------------------------- 1 | //! Terminal Universal Password Manager 2 | //! 3 | //! This is a terminal implementation of Universal Password Manager, allowing management of UPM 4 | //! databases and synchronization with remote HTTP repositories. 5 | 6 | extern crate chrono; 7 | extern crate clap; 8 | #[macro_use(wrap_impl)] 9 | extern crate cursive; 10 | extern crate base64; 11 | extern crate dirs; 12 | extern crate rpassword; 13 | extern crate upm; 14 | 15 | use chrono::prelude::*; 16 | use clap::{App, Arg, ArgMatches}; 17 | use std::fs; 18 | use std::path::{Path, PathBuf}; 19 | use std::process; 20 | use tupm::controller::Controller; 21 | use upm::database::Database; 22 | use upm::error::UpmError; 23 | use upm::sync; 24 | 25 | mod tupm { 26 | pub mod clipboard; 27 | pub mod controller; 28 | pub mod ui; 29 | } 30 | 31 | const DEFAULT_DATABASE_DIRECTORY: &'static str = ".tupm"; 32 | const DEFAULT_DATABASE_FILENAME: &'static str = "primary"; 33 | 34 | // Possible exit codes 35 | const EXIT_SUCCESS: i32 = 0; 36 | const EXIT_FAILURE: i32 = 1; 37 | 38 | // These functions supply optional fixed values for the database filename and password if built 39 | // with the test_database feature flag and invoked with the --test option. This is a convenience 40 | // for development. 41 | 42 | #[cfg(feature = "test_database")] 43 | fn test_filename(matches: &ArgMatches) -> Option { 44 | const TEST_DB_FILE: &'static str = "sampledb.upm"; 45 | if matches.is_present("test") { 46 | Some(PathBuf::from(TEST_DB_FILE)) 47 | } else { 48 | None 49 | } 50 | } 51 | #[cfg(not(feature = "test_database"))] 52 | fn test_filename(_: &ArgMatches) -> Option { 53 | None 54 | } 55 | #[cfg(feature = "test_database")] 56 | fn test_password(matches: &ArgMatches) -> Option<&'static str> { 57 | const TEST_PASSWORD: &'static str = "my!awesome!password@42"; 58 | if matches.is_present("test") { 59 | Some(TEST_PASSWORD) 60 | } else { 61 | None 62 | } 63 | } 64 | #[cfg(not(feature = "test_database"))] 65 | fn test_password(_: &ArgMatches) -> Option<&'static str> { 66 | None 67 | } 68 | 69 | /// Return the path to the default database (~/.tupm/primary), creating any intermediate 70 | /// directories if needed. The actual database file returned by this function may or may not exist 71 | /// yet. 72 | fn get_default_database_path() -> Result { 73 | // Expand ~/.tupm based on the HOME environment variable 74 | let mut path = match dirs::home_dir().map(|p| p.join(DEFAULT_DATABASE_DIRECTORY)) { 75 | Some(d) => d, 76 | None => return Err(UpmError::InvalidFilename), 77 | }; 78 | // Create the directory if it doesn't already exist. 79 | if !path.is_dir() { 80 | fs::create_dir_all(&path)?; 81 | } 82 | // Append the default database filename 83 | path.push(DEFAULT_DATABASE_FILENAME); 84 | Ok(path) 85 | } 86 | 87 | /// Open the database file at the specified path using the provided password. Print an error and 88 | /// exit if it cannot be opened, read, and decrypted. 89 | fn open_database_or_exit(filename: &PathBuf, password: &str) -> Database { 90 | match Database::load_from_file(filename, password) { 91 | Ok(database) => database, 92 | Err(e) => { 93 | println!("error opening database: {}", e); 94 | process::exit(EXIT_FAILURE); 95 | } 96 | } 97 | } 98 | 99 | /// Export the contents of the provided database as a text report on standard output. 100 | fn export(database: &Database) { 101 | // Sort accounts by name. 102 | let mut accounts = database.accounts.clone(); 103 | accounts.sort(); 104 | 105 | // Output current time and database metadata 106 | println!("# {}", Local::now().format("%a %b %d %T %Y %Z")); 107 | println!( 108 | "# revision={} url={} credentials={}", 109 | database.sync_revision, database.sync_url, database.sync_credentials 110 | ); 111 | 112 | // Short-form output 113 | macro_rules! exportfmt { 114 | () => { 115 | "{:-28} {:-35} {:-10}" 116 | }; 117 | }; 118 | println!(exportfmt!(), "account", "username", "password"); 119 | println!( 120 | exportfmt!(), 121 | "-------------------", "----------------------------------", "------------" 122 | ); 123 | for account in accounts.iter() { 124 | println!(exportfmt!(), account.name, account.user, account.password); 125 | } 126 | 127 | // Long-form output 128 | println!(); 129 | println!("Long-form output (including URLs and notes)"); 130 | println!("-------------------------------------------"); 131 | println!(); 132 | for account in accounts.iter() { 133 | // format notes 134 | let mut notes = account.notes.trim().to_string(); 135 | notes = notes.replace("\r\n", "\n"); 136 | notes = notes.replace("\n", "\n "); 137 | 138 | println!("Account: {}", account.name); 139 | println!("Username: {}", account.user); 140 | println!("Password: {}", account.password); 141 | println!("URL: {}", account.url); 142 | println!("Notes: {}", notes); 143 | println!(); 144 | } 145 | } 146 | 147 | /// Download a remote database and exit. This is useful for fetching a remote database for the 148 | /// first time. 149 | fn download(path: &Path, url: &str) { 150 | // Avoid overwriting any existing local database -- the user must manually remove the database 151 | // or specify an alternate path. 152 | if path.exists() { 153 | println!( 154 | "Error: This database already exists: {}", 155 | path.to_string_lossy() 156 | ); 157 | println!("(Delete this database or specify an alternate path with --database.)"); 158 | process::exit(EXIT_FAILURE); 159 | } 160 | 161 | let database_name = match Database::path_to_name(&path) { 162 | Ok(n) => n, 163 | Err(e) => { 164 | println!("Error: {}", e); 165 | process::exit(EXIT_FAILURE); 166 | } 167 | }; 168 | println!( 169 | "Downloading remote database \"{}\" from repository \"{}\".", 170 | database_name, url 171 | ); 172 | 173 | // Collect the repository credentials 174 | let username = rpassword::prompt_response_stdout("Repository username: ").unwrap_or_else(|e| { 175 | println!("Error reading username: {}", e); 176 | process::exit(EXIT_FAILURE); 177 | }); 178 | let password = rpassword::prompt_password_stdout("Repository password: ").unwrap_or_else(|e| { 179 | println!("Error reading password: {}", e); 180 | process::exit(EXIT_FAILURE); 181 | }); 182 | 183 | // Download 184 | let database_bytes = match sync::download(url, &username, &password, path) { 185 | Ok(d) => d, 186 | Err(e) => { 187 | println!("Error downloading database: {}", e); 188 | process::exit(EXIT_FAILURE); 189 | } 190 | }; 191 | println!("{} bytes downloaded from repository.", database_bytes.len()); 192 | 193 | // Save 194 | if let Err(e) = Database::save_raw_bytes(database_bytes, path) { 195 | println!("Error saving database: {}", e); 196 | process::exit(EXIT_FAILURE); 197 | } 198 | println!("Database written to: {}.", path.to_string_lossy()); 199 | } 200 | 201 | /// Parse the command-line arguments and present a user interface with the selected UPM database. 202 | fn main() { 203 | // Parse command-line arguments 204 | let app = App::new("Terminal Universal Password Manager") 205 | .version("0.1.0") 206 | .about("Provides a terminal interface to Universal Password Manager (UPM) databases.") 207 | .arg( 208 | Arg::with_name("database") 209 | .short("d") 210 | .long("database") 211 | .value_name("FILE") 212 | .help("Specify the path to the database.") 213 | .takes_value(true), 214 | ) 215 | .arg( 216 | Arg::with_name("password") 217 | .short("p") 218 | .long("password") 219 | .help("Prompt for a password."), 220 | ) 221 | .arg( 222 | Arg::with_name("export") 223 | .short("e") 224 | .long("export") 225 | .help("Export database to a flat text file."), 226 | ) 227 | .arg( 228 | Arg::with_name("download") 229 | .short("l") 230 | .long("download") 231 | .value_name("URL") 232 | .help("Download a remote database.") 233 | .takes_value(true), 234 | ); 235 | #[cfg(feature = "test_database")] 236 | let app = app.arg( 237 | Arg::with_name("test") 238 | .short("t") 239 | .long("test") 240 | .help("Loads ./sampledb.upm with a baked-in password."), 241 | ); 242 | let matches = app.get_matches(); 243 | 244 | // Determine the database path. 245 | let database_filename = matches 246 | .value_of("database") 247 | .map(|p| PathBuf::from(p)) 248 | .or(test_filename(&matches)) 249 | .unwrap_or(get_default_database_path().unwrap_or_else(|e| { 250 | println!("Error resolving default database path: {}", e); 251 | process::exit(EXIT_FAILURE); 252 | })); 253 | 254 | // Determine the database password, if possible 255 | let password = if matches.is_present("password") { 256 | Some( 257 | rpassword::prompt_password_stdout("Password: ").unwrap_or_else(|e| { 258 | println!("Error reading password: {}", e); 259 | process::exit(EXIT_FAILURE); 260 | }), 261 | ) 262 | } else { 263 | test_password(&matches).map(|p| String::from(p)) 264 | }; 265 | 266 | // Dispatch to non-UI tasks, if requested. 267 | if matches.is_present("export") { 268 | match password { 269 | Some(p) => export(&open_database_or_exit(&database_filename, p.as_str())), 270 | None => { 271 | println!("Cannot export without a password. Use --password to prompt."); 272 | process::exit(EXIT_FAILURE); 273 | } 274 | } 275 | process::exit(EXIT_SUCCESS); 276 | } 277 | if let Some(url) = matches.value_of("download") { 278 | download(&database_filename, url); 279 | process::exit(EXIT_SUCCESS); 280 | } 281 | 282 | // Launch the controller and UI. 283 | let controller = Controller::new(&database_filename, password); 284 | match controller { 285 | Ok(mut controller) => controller.run(), 286 | Err(e) => { 287 | println!("Error: {}", e); 288 | process::exit(EXIT_FAILURE); 289 | } 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/upm/sync.rs: -------------------------------------------------------------------------------- 1 | //! This module supports synchronizing a UPM database with a copy on a remote repository. The 2 | //! remote repository should be an HTTP or HTTPS server supporting the "download", "upload", and 3 | //! "delete" primitives of the UPM sync protocol. 4 | 5 | use reqwest::multipart; 6 | use std::io::Read; 7 | use std::path::{Path, PathBuf}; 8 | use std::str; 9 | use std::time::Duration; 10 | 11 | use backup; 12 | use database::Database; 13 | use error::UpmError; 14 | 15 | /// The UPM sync protocol's delete command. This is appended to the repository URL. 16 | const DELETE_CMD: &'static str = "deletefile.php"; 17 | /// The UPM sync protocol's upload command. This is appended to the repository URL. 18 | const UPLOAD_CMD: &'static str = "upload.php"; 19 | /// This field name is used for the database file when uploading. 20 | const UPM_UPLOAD_FIELD_NAME: &'static str = "userfile"; 21 | /// Abort the operation if the server doesn't respond for this time interval. 22 | const TIMEOUT_SECS: u64 = 10; 23 | 24 | /// The UPM sync protocol returns an HTTP body of "OK" if the request was successful, otherwise it 25 | /// returns one of these error codes: FILE_DOESNT_EXIST, FILE_WASNT_DELETED, FILE_ALREADY_EXISTS, 26 | /// FILE_WASNT_MOVED, FILE_WASNT_UPLOADED 27 | const UPM_SUCCESS: &'static str = "OK"; 28 | 29 | /// UPM sync protocol responses should never be longer than this size. 30 | const UPM_MAX_RESPONSE_CODE_LENGTH: usize = 64; 31 | 32 | /// The MIME type used when uploading a database. 33 | const DATABASE_MIME_TYPE: &'static str = "application/octet-stream"; 34 | 35 | impl From for UpmError { 36 | /// Convert a reqwest error into a `UpmError`. 37 | fn from(err: reqwest::Error) -> UpmError { 38 | UpmError::Sync(format!("{}", err)) 39 | } 40 | } 41 | 42 | /// A successful sync will result in one of these three conditions. 43 | pub enum SyncResult { 44 | /// The remote repository's copy of the database was replaced with the local copy. 45 | RemoteSynced, 46 | /// The local database was replaced with the remote repository's copy. 47 | LocalSynced, 48 | /// Neither the local database nor the remote database was changed, since they were both the 49 | /// same revision. 50 | NeitherSynced, 51 | } 52 | 53 | /// Provide basic access to the remote repository. 54 | struct Repository { 55 | url: String, 56 | http_username: String, 57 | http_password: String, 58 | client: reqwest::Client, 59 | } 60 | 61 | impl Repository { 62 | /// Create a new `Repository` struct with the provided URL and credentials. 63 | fn new(url: &str, http_username: &str, http_password: &str) -> Result { 64 | // Create a new reqwest client. 65 | let client = reqwest::Client::builder() 66 | .timeout(Duration::from_secs(TIMEOUT_SECS)) 67 | .build()?; 68 | 69 | Ok(Repository { 70 | url: String::from(url), 71 | http_username: String::from(http_username), 72 | http_password: String::from(http_password), 73 | client, 74 | }) 75 | } 76 | 77 | // 78 | // Provide the three operations of the UPM sync protocol: 79 | // Download, delete, and upload. 80 | // 81 | 82 | /// Download the remote database with the provided name. The database is returned in raw form 83 | /// as a byte buffer. 84 | fn download(&mut self, database_name: &str) -> Result, UpmError> { 85 | let url = self.make_url(database_name); 86 | 87 | // Send request 88 | let mut response = self 89 | .client 90 | .get(&url) 91 | .basic_auth(self.http_username.clone(), Some(self.http_password.clone())) 92 | .send()?; 93 | 94 | // Process response 95 | if !response.status().is_success() { 96 | return match response.status() { 97 | reqwest::StatusCode::NOT_FOUND => Err(UpmError::SyncDatabaseNotFound), 98 | _ => Err(UpmError::Sync(format!("{}", response.status()))), 99 | }; 100 | } 101 | let mut data: Vec = Vec::new(); 102 | response.read_to_end(&mut data)?; 103 | Ok(data) 104 | } 105 | 106 | /// Delete the specified database from the remote repository. 107 | fn delete(&mut self, database_name: &str) -> Result<(), UpmError> { 108 | let url = self.make_url(DELETE_CMD); 109 | 110 | // Send request 111 | let mut response = self 112 | .client 113 | .post(&url) 114 | .basic_auth(self.http_username.clone(), Some(self.http_password.clone())) 115 | .form(&[("fileToDelete", database_name)]) 116 | .send()?; 117 | 118 | // Process response 119 | self.check_response(&mut response)?; 120 | Ok(()) 121 | } 122 | 123 | /// Upload the provided database to the remote repository. The database is provided in raw 124 | /// form as a byte buffer. 125 | fn upload(&mut self, database_name: &str, database_bytes: Vec) -> Result<(), UpmError> { 126 | let url: String = self.make_url(UPLOAD_CMD); 127 | 128 | // Thanks to Sean (seanmonstar) for helping to translate this code to multipart code 129 | // of reqwest 130 | let part = multipart::Part::bytes(database_bytes.clone()) 131 | .file_name(database_name.to_string()) 132 | .mime_str(DATABASE_MIME_TYPE)?; 133 | 134 | let form = multipart::Form::new().part(UPM_UPLOAD_FIELD_NAME, part); 135 | 136 | // Send request 137 | let mut response = self.client.post(&url).multipart(form).send()?; 138 | 139 | // Process response 140 | self.check_response(&mut response)?; 141 | Ok(()) 142 | } 143 | 144 | /// Construct a URL by appending the provided string to the repository URL, adding a separating 145 | /// slash character if needed. 146 | fn make_url(&self, path_component: &str) -> String { 147 | if self.url.ends_with('/') { 148 | format!("{}{}", self.url, path_component) 149 | } else { 150 | format!("{}/{}", self.url, path_component) 151 | } 152 | } 153 | 154 | /// Confirm that the HTTP response was successful and valid. 155 | fn check_response(&self, response: &mut reqwest::Response) -> Result<(), UpmError> { 156 | if !response.status().is_success() { 157 | return Err(UpmError::Sync(format!("{}", response.status()))); 158 | } 159 | let mut response_code = String::new(); 160 | response.read_to_string(&mut response_code)?; 161 | if response_code.len() > UPM_MAX_RESPONSE_CODE_LENGTH { 162 | return Err(UpmError::Sync(format!( 163 | "Unexpected response from server ({} bytes)", 164 | response_code.len() 165 | ))); 166 | } 167 | if response_code != UPM_SUCCESS { 168 | return Err(UpmError::Sync(format!("Server error: {}", response_code))); 169 | } 170 | Ok(()) 171 | } 172 | } 173 | 174 | /// Download a database from the remote repository without performing any sync operation with a 175 | /// local database. This is useful when downloading an existing remote database for the first 176 | /// time. 177 | pub fn download>( 178 | repo_url: &str, 179 | repo_username: &str, 180 | repo_password: &str, 181 | database_filename: P, 182 | ) -> Result, UpmError> { 183 | let mut repo = Repository::new(repo_url, repo_username, repo_password)?; 184 | let name = Database::path_to_name(&database_filename)?; 185 | repo.download(&name) 186 | } 187 | 188 | /// Synchronize the local and remote databases using the UPM sync protocol. If an optional remote 189 | /// password is provided, it will be used when decrypting the remote database; otherwise, the 190 | /// password of the local database will be used. Return true if the caller needs to reload the 191 | /// local database. 192 | /// 193 | /// The sync logic is as follows: 194 | /// 195 | /// 1. Download the current remote database from the provided URL. 196 | /// - Attempt to decrypt this database with the master password. 197 | /// - If decryption fails, return 198 | /// [`UpmError::BadPassword`](../error/enum.UpmError.html#variant.BadPassword). (The caller 199 | /// may wish to prompt the user for the remote password, then try again.) 200 | /// 2. Take action based on the revisions of the local and remote database: 201 | /// - If the local revision is greater than the remote revision, upload the local database to 202 | /// the remote repository (overwriting the pre-existing remote database). 203 | /// - If the local revision is less than the remote revision, replace the local database with 204 | /// the remote database (overwriting the pre-existing local database). 205 | /// - If the local revision is the same as the remote revision, then do nothing. 206 | /// 3. The caller may wish to mimic the behavior of the UPM Java application by considering the 207 | /// local database to be dirty if it has not been synced in 5 minutes. 208 | /// 209 | /// NOTE: It is theoretically possible for two UPM clients to revision the database separately 210 | /// before syncing, and result in a situation where one will "win" and the other will have its 211 | /// changes silently lost. The caller should exercise the appropriate level of paranoia to 212 | /// mitigate this risk. For example, prompting for sync before the user begins making a 213 | /// modification, and marking the database as dirty after 5 minutes. 214 | pub fn sync(database: &Database, remote_password: Option<&str>) -> Result { 215 | // Collect all the facts. 216 | if database.sync_url.is_empty() { 217 | return Err(UpmError::NoSyncURL); 218 | } 219 | if database.sync_credentials.is_empty() { 220 | return Err(UpmError::NoSyncCredentials); 221 | } 222 | let sync_account = match database.account(&database.sync_credentials) { 223 | Some(a) => a, 224 | None => return Err(UpmError::NoSyncCredentials), 225 | }; 226 | let database_filename = match database.path() { 227 | Some(f) => f, 228 | None => return Err(UpmError::NoDatabaseFilename), 229 | }; 230 | let database_name = match database.name() { 231 | Some(n) => n, 232 | None => return Err(UpmError::NoDatabaseFilename), 233 | }; 234 | 235 | let local_password = match database.password() { 236 | Some(p) => p, 237 | None => return Err(UpmError::NoDatabasePassword), 238 | }; 239 | let remote_password = match remote_password { 240 | Some(p) => p, 241 | None => local_password, 242 | }; 243 | 244 | // 1. Download the remote database. 245 | // If the remote database has a different password than the local 246 | // database, we will return UpmError::BadPassword and the caller can 247 | // prompt the user for the remote password, and call this function 248 | // again with Some(remote_password). 249 | let mut repo = Repository::new( 250 | &database.sync_url, 251 | &sync_account.user, 252 | &sync_account.password, 253 | )?; 254 | let remote_exists; 255 | let mut remote_database = match repo.download(database_name) { 256 | Ok(bytes) => { 257 | remote_exists = true; 258 | Database::load_from_bytes(&bytes, remote_password)? 259 | } 260 | Err(UpmError::SyncDatabaseNotFound) => { 261 | // No remote database with that name exists, so this must be a fresh sync. 262 | // We'll use a stub database with revision 0. 263 | remote_exists = false; 264 | Database::new() 265 | } 266 | Err(e) => return Err(e), 267 | }; 268 | 269 | // 2. Copy databases as needed. 270 | if database.sync_revision > remote_database.sync_revision { 271 | // Copy the local database to the remote. 272 | 273 | // First, upload a backup copy in case something goes wrong between delete() and upload(). 274 | if super::PARANOID_BACKUPS { 275 | let backup_database_path = 276 | backup::generate_backup_filename(&PathBuf::from(database_name))?; 277 | let backup_database_name = backup_database_path.to_str(); 278 | if let Some(backup_database_name) = backup_database_name { 279 | repo.upload( 280 | backup_database_name, 281 | database.save_to_bytes(remote_password)?, 282 | )?; 283 | } 284 | } 285 | 286 | // Delete the existing remote database, if it exists. 287 | if remote_exists { 288 | repo.delete(&database_name)?; 289 | } 290 | 291 | // Upload the local database to the remote. Make sure to re-encrypt with the local 292 | // password, in case it has been changed recently. 293 | repo.upload(database_name, database.save_to_bytes(local_password)?)?; 294 | Ok(SyncResult::RemoteSynced) 295 | } else if database.sync_revision < remote_database.sync_revision { 296 | // Replace the local database with the remote database 297 | remote_database.set_path(&database_filename)?; 298 | remote_database.save()?; 299 | // The caller should reload the local database when it receives this result. 300 | Ok(SyncResult::LocalSynced) 301 | } else { 302 | // Revisions are the same -- do nothing. 303 | Ok(SyncResult::NeitherSynced) 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /src/bin/tupm/controller.rs: -------------------------------------------------------------------------------- 1 | //! Provide the core logic of the `tupm` application: receive events from the UI and perform 2 | //! operations on the currently loaded database. 3 | //! 4 | 5 | extern crate upm; 6 | 7 | use std::io; 8 | use std::path::PathBuf; 9 | use std::sync::mpsc; 10 | use tupm; 11 | use upm::backup::backup; 12 | use upm::database::{Account, Database}; 13 | use upm::error::UpmError; 14 | use upm::sync; 15 | use upm::sync::SyncResult; 16 | 17 | /// The controller maintains a message queue consisting of zero or more of these messages. Other 18 | /// components (mostly likely the UI) can add messages to the queue, and the controller will 19 | /// process them in order. 20 | #[derive(Debug)] 21 | pub enum Message { 22 | AccountEdit(Option, Option), 23 | DatabaseEdit(String, String), 24 | Sync, 25 | ChangePassword(String), 26 | Quit, 27 | } 28 | 29 | /// This struct provides the core logic of the `tupm` application. It fulfills the role of the 30 | /// controller in the Model-View-Controller (MVC) design pattern. (The Ui class provides the view, 31 | /// and the Database class provides the model.) 32 | pub struct Controller { 33 | rx: mpsc::Receiver, 34 | ui: tupm::ui::Ui, 35 | database: Database, 36 | } 37 | 38 | impl Controller { 39 | /// Create a new controller with the provided database path and password. This will load the 40 | /// database (if possible) and initialize the user interface. 41 | pub fn new(database_path: &PathBuf, password: Option) -> Result { 42 | let (tx, rx) = mpsc::channel::(); 43 | let mut ui = tupm::ui::Ui::new(tx.clone()); 44 | let mut fresh_database = false; 45 | let mut database_try: Option; 46 | let mut database; 47 | let mut retry; 48 | let mut subsequent_bad_password = false; 49 | 50 | // Prompt for a password if none was supplied. 51 | let mut password = match password { 52 | Some(p) => p, 53 | None => { 54 | subsequent_bad_password = true; 55 | match Controller::password_prompt(&mut ui) { 56 | Some(p) => p, 57 | None => return Err(UpmError::NoDatabasePassword), 58 | } 59 | } 60 | }; 61 | 62 | // This awkward syntax is how a do-while is implemented in Rust. 63 | while { 64 | retry = false; 65 | database_try = match Database::load_from_file(database_path, &password) { 66 | Ok(mut database) => { 67 | database.accounts.sort(); 68 | ui.set_statusline(&format!("Database loaded from {}", database_path.display())); 69 | Some(database) 70 | } 71 | Err(UpmError::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => { 72 | ui.set_statusline("No existing database found -- creating a new database."); 73 | fresh_database = true; 74 | None 75 | } 76 | Err(UpmError::BadPassword) => { 77 | if subsequent_bad_password { 78 | ui.notice_dialog( 79 | "Bad password", 80 | "The provided password is invalid for this database.", 81 | ); 82 | } else { 83 | subsequent_bad_password = true; 84 | } 85 | match Controller::password_prompt(&mut ui) { 86 | Some(p) => { 87 | password = p; 88 | retry = true; 89 | } 90 | None => return Err(UpmError::NoDatabasePassword), 91 | }; 92 | None 93 | } 94 | Err(e) => { 95 | ui.notice_dialog( 96 | "Unrecoverable error", 97 | &format!( 98 | "The database could not be opened for the \ 99 | following reason:\n\n{}\n\nThe program will \ 100 | now exit.", 101 | e 102 | ), 103 | ); 104 | ui.quit(); 105 | None 106 | } 107 | }; 108 | retry 109 | } {} 110 | database = match database_try { 111 | Some(d) => d, 112 | None => Database::new(), 113 | }; 114 | 115 | ui.set_database(&database); 116 | 117 | // Fresh databases require a master password before proceeding. 118 | if fresh_database { 119 | database.set_path(database_path)?; 120 | if database.password().is_none() { 121 | database.set_password(&password); 122 | } 123 | if let Err(e) = database.save() { 124 | ui.set_statusline(&format!("{}", e)); 125 | } else { 126 | ui.set_statusline(&format!( 127 | "New database created at: {}", 128 | database_path.display() 129 | )); 130 | } 131 | } 132 | 133 | Ok(Controller { rx, ui, database }) 134 | } 135 | 136 | /// Continuously prompt for a password until either one is provided or the user decides to 137 | /// quit. 138 | fn password_prompt(ui: &mut tupm::ui::Ui) -> Option { 139 | let mut password = None; 140 | while password.is_none() { 141 | password = match ui 142 | .password_dialog("Please provide a master password for the database:", true) 143 | { 144 | Some(p) => Some(p), 145 | None => { 146 | if ui.yesno_dialog( 147 | "Password required", 148 | "A master password for the database is required to continue.", 149 | "OK", 150 | "Exit", 151 | ) { 152 | ui.quit(); 153 | return None; 154 | } else { 155 | None 156 | } 157 | } 158 | }; 159 | } 160 | password 161 | } 162 | 163 | /// Run the controller. This method contains the main loop which will step the UI and process 164 | /// events until the user quits the application. 165 | pub fn run(&mut self) { 166 | while self.ui.step() { 167 | while let Some(message) = self.next_message() { 168 | // Dispatch to handler functions as needed. 169 | match message { 170 | Message::AccountEdit(before, after) => self.handle_account_edit(before, after), 171 | Message::DatabaseEdit(url, credentials) => { 172 | self.handle_database_edit(url, credentials) 173 | } 174 | Message::Sync => { 175 | self.handle_sync(None).ok(); 176 | () 177 | } 178 | Message::ChangePassword(password) => { 179 | self.handle_change_password(password); 180 | } 181 | Message::Quit => { 182 | self.ui.quit(); 183 | } 184 | }; 185 | } 186 | } 187 | } 188 | 189 | /// Return the next message in the message queue, if one is present. 190 | fn next_message(&self) -> Option { 191 | self.rx.try_iter().next() 192 | } 193 | 194 | /// Process an account change, creation, or deletion. 195 | fn handle_account_edit(&mut self, before: Option, after: Option) { 196 | let mut modified = false; 197 | 198 | if let (&Some(ref before), &Some(ref after)) = (&before, &after) { 199 | // Update account 200 | if before != after { 201 | if let Err(e) = self.database.update_account(&before.name, &after) { 202 | self.ui.set_statusline(&format!("Error: {}", e)); 203 | return; 204 | } 205 | modified = true; 206 | } 207 | } else if let (&None, &Some(ref account)) = (&before, &after) { 208 | // Create account 209 | if let Err(e) = self.database.add_account(account) { 210 | self.ui.set_statusline(&format!("Error: {}", e)); 211 | return; 212 | } 213 | modified = true; 214 | } else if let (&Some(ref account), &None) = (&before, &after) { 215 | // Delete account 216 | self.database.delete_account(account.name.as_str()); 217 | modified = true; 218 | } 219 | 220 | if modified { 221 | self.handle_save_database(); 222 | self.database.clear_synced(); 223 | } 224 | 225 | // Reload the UI with the modified database. 226 | self.database.accounts.sort(); 227 | self.ui.set_database(&self.database); 228 | 229 | // set_database() will try to preserve the selection based on its index, 230 | // but since the user can change the account name which can result in the 231 | // account having a different sorted position, we'll try to re-focus the 232 | // specific account here. 233 | if let Some(account) = after { 234 | self.ui.focus_account(&account.name); 235 | } 236 | 237 | self.ui.update_status(); 238 | } 239 | 240 | /// Process a change to the database properties (URL, credentials). 241 | fn handle_database_edit(&mut self, url: String, credentials: String) { 242 | if (&url, &credentials) != (&self.database.sync_url, &self.database.sync_credentials) { 243 | self.database.sync_url = url; 244 | self.database.sync_credentials = credentials; 245 | self.handle_save_database(); 246 | self.database.clear_synced(); 247 | self.ui.set_database(&self.database); 248 | } 249 | self.ui.update_status(); 250 | } 251 | 252 | /// Process a sync. 253 | fn handle_sync(&mut self, remote_password: Option<&str>) -> Result<(), UpmError> { 254 | match sync::sync(&self.database, remote_password) { 255 | Ok(SyncResult::RemoteSynced) => { 256 | self.ui.set_statusline(&format!( 257 | "Remote database synced to revision {}", 258 | self.database.sync_revision 259 | )); 260 | self.database.set_synced(); 261 | self.ui.set_database(&self.database); // So the UI gets new sync status 262 | Ok(()) 263 | } 264 | Ok(SyncResult::LocalSynced) => { 265 | // Reload local database 266 | match Database::load_from_file( 267 | self.database.path().unwrap(), 268 | self.database.password().unwrap(), 269 | ) { 270 | Ok(mut reloaded_database) => { 271 | reloaded_database.accounts.sort(); 272 | self.database = reloaded_database; 273 | self.ui.set_database(&self.database); 274 | self.ui.set_statusline(&format!( 275 | "Local database synced to revision {}", 276 | self.database.sync_revision 277 | )); 278 | } 279 | Err(e) => { 280 | self.ui 281 | .set_statusline(&format!("error reloading local database: {}", e)); 282 | } 283 | }; 284 | self.database.set_synced(); 285 | self.ui.set_database(&self.database); // So the UI gets new sync status 286 | Ok(()) 287 | } 288 | Ok(SyncResult::NeitherSynced) => { 289 | self.ui.set_statusline(&format!( 290 | "Both local and remote databases are in sync to revision {}.", 291 | self.database.sync_revision 292 | )); 293 | self.database.set_synced(); 294 | self.ui.set_database(&self.database); // So the UI gets new sync status 295 | Ok(()) 296 | } 297 | Err(UpmError::BadPassword) => { 298 | if remote_password.is_none() { 299 | // Prompt for remote database password and try again 300 | let password = self.ui.password_dialog( 301 | "The remote database uses a different password. \ 302 | Please supply the password to the remote database:", 303 | true, 304 | ); 305 | if let Some(password) = password { 306 | self.handle_sync(Some(&password)) 307 | } else { 308 | Ok(()) 309 | } 310 | } else { 311 | // Prevent arbitrary-depth recursion by only asking for the remote database 312 | // password once. 313 | self.ui 314 | .notice_dialog("Bad password", "Bad password for the remote database."); 315 | self.ui.set_statusline(&format!( 316 | "Cannot sync: Bad password for the remote database." 317 | )); 318 | Err(UpmError::Sync(String::from( 319 | "Bad password for the remote database.", 320 | ))) 321 | } 322 | } 323 | Err(e) => { 324 | self.ui.set_statusline(&format!("Cannot sync: {}", e)); 325 | Err(UpmError::Sync(format!("Cannot sync: {}", e))) 326 | } 327 | } 328 | } 329 | 330 | /// Process a request to change the database password. 331 | fn handle_change_password(&mut self, new_password: String) { 332 | self.database.set_password(&new_password); 333 | if let Err(e) = self.save_database() { 334 | self.ui.set_statusline(&format!("{}", e)); 335 | } else { 336 | self.ui.set_statusline("Password updated."); 337 | } 338 | self.database.clear_synced(); 339 | self.ui.set_database(&self.database); 340 | } 341 | 342 | /// Save the database to the local filesystem. This is the basic function which increments the 343 | /// revision and makes any needed backups before saving. 344 | fn save_database(&mut self) -> Result<(), UpmError> { 345 | // Bump the revision 346 | self.database.sync_revision += 1; 347 | 348 | // Make a backup of the old database, if present. 349 | if upm::PARANOID_BACKUPS { 350 | if let Some(f) = self.database.path() { 351 | if let Err(e) = backup(&f) { 352 | return Err(UpmError::Backup(format!( 353 | "Error making backup; not saved: {}", 354 | e 355 | ))); 356 | } 357 | } 358 | } 359 | 360 | // Save the database 361 | self.database.save()?; 362 | Ok(()) 363 | } 364 | 365 | /// Save the database to the local filesystem. This is the function called when the user 366 | /// explicitly requests a save. It calls save_database(), processes the result, and updates 367 | /// the UI status line accordingly. 368 | fn handle_save_database(&mut self) { 369 | match self.save_database() { 370 | Ok(()) => { 371 | self.ui.set_statusline(&format!( 372 | "Database saved to {}", 373 | self.database.path().unwrap().display() 374 | )); 375 | } 376 | Err(e) => { 377 | self.ui.set_statusline(&format!("{}", e)); 378 | } 379 | }; 380 | self.ui.update_status(); 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /src/upm/database.rs: -------------------------------------------------------------------------------- 1 | //! Read and write Universal Password Manager version 3 databases. This code is meant to 2 | //! interoperate with the format used by [the original UPM Java 3 | //! application](https://github.com/adrian/upm-swing). 4 | //! 5 | //! Versions 1 and 2 of the UPM database format are not supported. Version 3 was introduced in 6 | //! 2011, so there may not be many cases where the older versions are still in use. 7 | //! 8 | //! # Database format 9 | //! 10 | //! UPMv3 databases are stored in the following format: 11 | //! 12 | //! * A 3-byte magic field ("UPM"). 13 | //! * A 1-byte version field. (This module only supports version 3.) 14 | //! * The 8-byte salt used to encrypt the remainder of the file. 15 | //! * The remainder of the file is encrypted using 256-bit AES-CBC (see the documentation for the 16 | //! [`crypto`] module for more details). When decrypted, the plaintext will contain a series of 17 | //! length-prefixed records in a format that the original UPM author refers to as "flatpack". 18 | //! The length prefix is four bytes of UTF-8 encoded decimal which specifies the size in bytes of 19 | //! the record payload which follows. The payload is always a UTF-8 string; integers are encoded 20 | //! as decimal digits. 21 | //! - The first three records are metadata: 22 | //! 1. The database revision, a monotonically increasing number that is used when syncing 23 | //! with a remote database. 24 | //! 2. The URL of the remote sync repository. This URL does not include the name of the 25 | //! database. It instead corresponds to a directory on the server which may include 26 | //! multiple UPM databases with different names. 27 | //! 3. The name of the account, as included in this database, which contains the username 28 | //! and password to be used for HTTP Basic Authentication when accessing the remote sync 29 | //! repository. 30 | //! - The remaining records contain account data. Every five records represents the following 31 | //! data for a specific account: 32 | //! 1. Account name 33 | //! 2. Username 34 | //! 3. Password 35 | //! 4. URL 36 | //! 5. Notes 37 | 38 | use crypto; 39 | use error::UpmError; 40 | use rand::{OsRng, Rng}; 41 | use std::cmp::Ordering; 42 | use std::collections::HashSet; 43 | use std::fmt; 44 | use std::fs; 45 | use std::fs::File; 46 | use std::io; 47 | use std::io::Read; 48 | use std::io::Write; 49 | use std::path::{Path, PathBuf}; 50 | use std::str; 51 | use std::time::Duration; 52 | use std::time::Instant; 53 | 54 | /// The size in bytes of the UPM header magic field. 55 | const MAGIC_SIZE: usize = 3; 56 | /// The expected magic. 57 | const UPM_MAGIC: [u8; MAGIC_SIZE] = ['U' as u8, 'P' as u8, 'M' as u8]; 58 | /// The size in bytes of the UPM header version field. 59 | const UPM_DB_VERSION_SIZE: usize = 1; 60 | /// The expected database version. 61 | const UPM_DB_VERSION: u8 = 3; 62 | /// The size in bytes of the header salt field. 63 | const SALT_SIZE: usize = 8; 64 | 65 | /// After this much time elapses from the last synch, the database will once again be considered 66 | /// unsynced (i.e. dirty). This mimics the behavior of the java-swing UPM client. 67 | const SYNC_VALIDITY_SECS: u64 = 300; // 5 minutes 68 | 69 | /// A flatpack record cannot contain more than 9999 bytes. 70 | const FLATPACK_MAX_RECORD_SIZE: usize = 9999; 71 | 72 | /// This struct provides a means of consuming flatpack records from a binary buffer. 73 | /// 74 | /// Flatpack data contains a series of length-prefixed records. The length prefix is four bytes of 75 | /// UTF-8 encoded decimal which specifies the size in bytes of the record payload which follows. 76 | /// The payload is always a UTF-8 string; integers are encoded as decimal digits. 77 | struct FlatpackParser { 78 | buffer: Vec, 79 | position: usize, 80 | error: bool, 81 | } 82 | 83 | impl<'a> Iterator for FlatpackParser { 84 | type Item = Result; 85 | 86 | /// Return the next record, if present. Return `None` when the end of iteration is reached. 87 | fn next(&mut self) -> Option> { 88 | fn make_error(message: &str) -> Option> { 89 | Some(Err(UpmError::AccountParse(Some(String::from(message))))) 90 | } 91 | 92 | // Handle exceptional conditions. 93 | if self.error { 94 | return None; 95 | } 96 | if self.position == self.buffer.len() { 97 | return None; 98 | } 99 | if self.position > self.buffer.len() - 4 { 100 | self.error = true; 101 | return make_error("buffer underrun while parsing length prefix"); 102 | } 103 | 104 | // Extract the length prefix. 105 | let mut size: usize = 0; 106 | for i in 0..4 { 107 | let c = self.buffer[self.position + i]; 108 | if c < '0' as u8 || c > '9' as u8 { 109 | self.error = true; 110 | return make_error("invalid byte in length prefix"); 111 | } 112 | size += ((c - ('0' as u8)) as usize) * 10usize.pow(3 - (i as u32)); 113 | } 114 | self.position += 4; 115 | 116 | // Extract the payload 117 | if self.position + size > self.buffer.len() { 118 | self.error = true; 119 | return make_error("buffer underrun while parsing payload"); 120 | } 121 | let payload_bytes = &self.buffer[self.position..self.position + size]; 122 | let payload = match str::from_utf8(payload_bytes) { 123 | Ok(s) => String::from(s), 124 | Err(e) => return Some(Err(UpmError::AccountParse(Some(format!("{}", e))))), 125 | }; 126 | self.position += size; 127 | 128 | Some(Ok(payload)) 129 | } 130 | } 131 | 132 | impl FlatpackParser { 133 | /// Construct a new flatpack parser with the provided byte buffer. 134 | fn new(buffer: Vec) -> FlatpackParser { 135 | FlatpackParser { 136 | buffer: buffer, 137 | position: 0, 138 | error: false, 139 | } 140 | } 141 | 142 | /// Parse and return the next `count` records. 143 | fn get(&mut self, count: usize) -> Result, UpmError> { 144 | let mut items: Vec = Vec::new(); 145 | for _ in 0..count { 146 | items.push(match self.next() { 147 | Some(Ok(s)) => s, 148 | Some(Err(e)) => return Err(e), 149 | None => { 150 | return Err(UpmError::AccountParse(Some(String::from( 151 | "record underrun", 152 | )))); 153 | } 154 | }); 155 | } 156 | return Ok(items); 157 | } 158 | 159 | /// Return true if the parser has reached the end of the flatpack data. 160 | fn eof(&self) -> bool { 161 | self.position == self.buffer.len() 162 | } 163 | 164 | /// Convenience function to return a 3-tuple of the next three records. 165 | fn take3(&mut self) -> Result<(String, String, String), UpmError> { 166 | let mut v = self.get(3)?; 167 | Ok((v.remove(0), v.remove(0), v.remove(0))) 168 | } 169 | 170 | /// Convenience function to return a 5-tuple of the next five records. 171 | fn take5(&mut self) -> Result<(String, String, String, String, String), UpmError> { 172 | let mut v = self.get(5)?; 173 | Ok(( 174 | v.remove(0), 175 | v.remove(0), 176 | v.remove(0), 177 | v.remove(0), 178 | v.remove(0), 179 | )) 180 | } 181 | } 182 | 183 | /// This struct provides a means of encoding data as flatpack records. 184 | struct FlatpackWriter { 185 | buffer: Vec, 186 | } 187 | 188 | impl FlatpackWriter { 189 | /// Construct a new flatpack writer. 190 | fn new() -> FlatpackWriter { 191 | FlatpackWriter { 192 | buffer: Vec::::new(), 193 | } 194 | } 195 | 196 | /// Add a record containing the provided bytes. 197 | fn put_bytes(&mut self, data: &[u8]) -> Result<(), UpmError> { 198 | // Validate record length 199 | if data.len() > FLATPACK_MAX_RECORD_SIZE { 200 | return Err(UpmError::FlatpackOverflow); 201 | } 202 | // Write the length prefix 203 | self.buffer.extend(format!("{:04}", data.len()).as_bytes()); 204 | // Write the data 205 | self.buffer.extend(data); 206 | Ok(()) 207 | } 208 | 209 | /// Add a record containing the provided string. 210 | fn put_string(&mut self, data: &str) -> Result<(), UpmError> { 211 | self.put_bytes(data.as_bytes())?; 212 | Ok(()) 213 | } 214 | 215 | /// Add a record containing the provided integer. 216 | fn put_u32(&mut self, number: u32) -> Result<(), UpmError> { 217 | self.put_string(&(format!("{}", number)))?; 218 | Ok(()) 219 | } 220 | } 221 | 222 | /// This struct represents a single UPM account, and provides an ordering based on the 223 | /// alphanumeric case-insensitive comparison of account names. 224 | #[derive(Clone, Debug, PartialEq, Eq)] 225 | pub struct Account { 226 | pub name: String, 227 | pub user: String, 228 | pub password: String, 229 | pub url: String, 230 | pub notes: String, 231 | } 232 | 233 | impl Account { 234 | /// Create a new Account struct. All fields are initialized to empty strings. 235 | pub fn new() -> Account { 236 | Account { 237 | name: String::new(), 238 | user: String::new(), 239 | password: String::new(), 240 | url: String::new(), 241 | notes: String::new(), 242 | } 243 | } 244 | } 245 | 246 | impl Ord for Account { 247 | /// Provide an ordering of accounts based on a case-insensitive comparison of account names. 248 | fn cmp(&self, other: &Account) -> Ordering { 249 | self.name.to_lowercase().cmp(&other.name.to_lowercase()) 250 | } 251 | } 252 | 253 | impl PartialOrd for Account { 254 | fn partial_cmp(&self, other: &Account) -> Option { 255 | Some(self.cmp(other)) 256 | } 257 | } 258 | 259 | /// This struct represents a UPM database, as read from a local file or a remote sync repository. 260 | #[derive(Clone)] 261 | pub struct Database { 262 | pub sync_revision: u32, 263 | pub sync_url: String, 264 | pub sync_credentials: String, 265 | pub accounts: Vec, 266 | /// Track the filename originally used to load this file. This will be used when saving and 267 | /// syncing with a remote repository. 268 | path: Option, 269 | /// Track the password used to decrypt this database, so it can be used to re-encrypt when 270 | /// saving and syncing. 271 | password: Option, 272 | /// Record the time of last sync. Some edit features only work when the database has been 273 | /// recently synced. 274 | last_synced: Option, 275 | } 276 | 277 | impl fmt::Debug for Database { 278 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 279 | write!( 280 | f, 281 | "Database[r={},a={}]", 282 | self.sync_revision, 283 | self.accounts.len() 284 | ) 285 | } 286 | } 287 | 288 | impl Database { 289 | /// Construct a fresh, empty database. 290 | pub fn new() -> Database { 291 | Database { 292 | sync_revision: 0, 293 | sync_url: String::new(), 294 | sync_credentials: String::new(), 295 | accounts: vec![], 296 | path: None, 297 | password: None, 298 | last_synced: None, 299 | } 300 | } 301 | 302 | /// Load and decrypt a database from an in-memory byte slice using the provided password. 303 | pub fn load_from_bytes(bytes: &[u8], password: &str) -> Result { 304 | // Remove a number of bytes from a byte buffer. Return a tuple containing the removed bytes 305 | // and the remaining bytes. 306 | fn unshift(bytes: &[u8], size: usize) -> (&[u8], &[u8]) { 307 | (&bytes[0..size], &bytes[size..]) 308 | } 309 | 310 | // Parse the unencrypted header 311 | const HEADER_SIZE: usize = MAGIC_SIZE + UPM_DB_VERSION_SIZE + SALT_SIZE; 312 | if bytes.len() < HEADER_SIZE { 313 | return Err(UpmError::ReadUnderrun); 314 | } 315 | let (magic, remainder) = unshift(bytes, MAGIC_SIZE); 316 | if magic != UPM_MAGIC { 317 | return Err(UpmError::BadMagic); 318 | } 319 | let (db_version, remainder) = unshift(remainder, UPM_DB_VERSION_SIZE); 320 | if db_version[0] != UPM_DB_VERSION { 321 | return Err(UpmError::BadVersion(db_version[0])); 322 | } 323 | let (salt, ciphertext) = unshift(remainder, SALT_SIZE); 324 | 325 | // Decrypt the ciphertext 326 | let plaintext = crypto::decrypt(&ciphertext, password, &salt)?; 327 | 328 | // The resulting plaintext is encoded as a series of "flatpack" records. 329 | let mut pack = FlatpackParser::new(plaintext); 330 | 331 | // The initial three elements are metadata. 332 | let (sync_revision, sync_url, sync_credentials) = pack.take3()?; 333 | let sync_revision: u32 = match sync_revision.parse() { 334 | Ok(r) => r, 335 | Err(_) => { 336 | return Err(UpmError::AccountParse(Some(String::from( 337 | "cannot parse revision number", 338 | )))); 339 | } 340 | }; 341 | 342 | // Accounts follow in groups of five elements. 343 | let mut accounts: Vec = Vec::new(); 344 | while !pack.eof() { 345 | let elements = pack.take5()?; 346 | let record = Account { 347 | name: elements.0, 348 | user: elements.1, 349 | password: elements.2, 350 | url: elements.3, 351 | notes: elements.4, 352 | }; 353 | accounts.push(record); 354 | } 355 | 356 | // Assure account names are unique when loading, so we can rely on this as a key later. 357 | let mut account_names = HashSet::new(); 358 | for ref account in &accounts { 359 | if account_names.contains(&account.name) { 360 | return Err(UpmError::DuplicateAccountName(account.name.clone())); 361 | } 362 | account_names.insert(account.name.clone()); 363 | } 364 | 365 | Ok(Database { 366 | sync_revision: sync_revision, 367 | sync_url: sync_url, 368 | sync_credentials: sync_credentials, 369 | accounts: accounts, 370 | path: None, 371 | password: Some(String::from(password)), 372 | last_synced: None, 373 | }) 374 | } 375 | 376 | /// Load and decrypt a database from the given filename using the provided password. 377 | pub fn load_from_file>( 378 | filename: P, 379 | password: &str, 380 | ) -> Result { 381 | let mut file = File::open(filename.as_ref())?; 382 | let mut bytes: Vec = Vec::new(); 383 | file.read_to_end(&mut bytes)?; 384 | drop(file); 385 | let mut database = Database::load_from_bytes(&bytes, password)?; 386 | database.set_path(&filename.as_ref())?; 387 | Ok(database) 388 | } 389 | 390 | /// Save the database locally using the same filename previously used to load the database. 391 | pub fn save(&self) -> Result<(), UpmError> { 392 | let filename = match self.path() { 393 | Some(f) => f, 394 | None => return Err(UpmError::NoDatabaseFilename), 395 | }; 396 | let password = match self.password() { 397 | Some(p) => p, 398 | None => return Err(UpmError::NoDatabasePassword), 399 | }; 400 | self.save_as(filename, password) 401 | } 402 | 403 | /// Save the database locally using the provided filename and password. 404 | pub fn save_as(&self, filename: &Path, password: &str) -> Result<(), UpmError> { 405 | let bytes = self.save_to_bytes(password)?; 406 | Self::save_raw_bytes(bytes, filename)?; 407 | Ok(()) 408 | } 409 | 410 | /// Save an already-encoded database locally using the provided filename. 411 | pub fn save_raw_bytes>(bytes: Vec, filename: P) -> Result<(), UpmError> { 412 | // First write to a temporary file, then rename(). This avoids 413 | // destroying the existing file if an I/O error occurs (e.g. out 414 | // of space). 415 | 416 | // Determine the temporary filename 417 | Self::validate_path(&filename)?; 418 | let filename = filename.as_ref(); 419 | let tmp_filename = PathBuf::from(String::from(filename.to_str().unwrap()) + ".tmp"); 420 | 421 | // Remove any existing temporary file, if present. 422 | match fs::remove_file(&tmp_filename) { 423 | Ok(_) => {} 424 | Err(ref e) if e.kind() == io::ErrorKind::NotFound => {} 425 | Err(e) => return Err(UpmError::Io(e)), 426 | } 427 | 428 | // Write the file. 429 | { 430 | // Use a separate lexical scope for the file, so it will be 431 | // flushed and closed before we rename. (Renaming an open 432 | // file probably isn't an issue under Unix, but I'm not 433 | // sure about other operating systems.) 434 | let mut file = File::create(&tmp_filename)?; 435 | file.write_all(&bytes)?; 436 | } 437 | // Rename the temporary file to the real filename. 438 | fs::rename(tmp_filename, filename)?; 439 | Ok(()) 440 | } 441 | 442 | /// Save the database to an in-memory byte buffer. This is useful, for example, when sending 443 | /// the database to a remote sync repository. 444 | pub fn save_to_bytes(&self, password: &str) -> Result, UpmError> { 445 | let mut buffer: Vec = vec![]; 446 | 447 | // Generate a salt 448 | let mut rng = OsRng::new().ok().unwrap(); 449 | let mut salt = [0u8; SALT_SIZE]; 450 | rng.fill_bytes(&mut salt); 451 | 452 | // Write unencrypted metadata 453 | buffer.extend_from_slice(&UPM_MAGIC); 454 | buffer.extend_from_slice(&[UPM_DB_VERSION]); 455 | buffer.extend_from_slice(&salt); 456 | 457 | // Write encrypted metadata 458 | let mut pack = FlatpackWriter::new(); 459 | pack.put_u32(self.sync_revision)?; 460 | pack.put_string(&self.sync_url)?; 461 | pack.put_string(&self.sync_credentials)?; 462 | 463 | // Write accounts 464 | for account in self.accounts.iter() { 465 | pack.put_string(&account.name)?; 466 | pack.put_string(&account.user)?; 467 | pack.put_string(&account.password)?; 468 | pack.put_string(&account.url)?; 469 | pack.put_string(&account.notes)?; 470 | } 471 | 472 | // Encrypt and write to the file 473 | let ciphertext = crypto::encrypt(&pack.buffer, password, &salt)?; 474 | buffer.extend_from_slice(ciphertext.as_slice()); 475 | Ok(buffer) 476 | } 477 | 478 | /// Return a reference to the named account. 479 | pub fn account(&self, name: &str) -> Option<&Account> { 480 | self.accounts.iter().find(|a| a.name == name) 481 | } 482 | 483 | /// Return a mutable reference to the named account. 484 | pub fn account_mut(&mut self, name: &str) -> Option<&mut Account> { 485 | self.accounts.iter_mut().find(|a| a.name == name) 486 | } 487 | 488 | /// Return true if the database contains an account with the specified name; otherwise return 489 | /// false. 490 | pub fn contains(&self, name: &str) -> bool { 491 | self.accounts.iter().any(|a| a.name == name) 492 | } 493 | 494 | /// Update the named account with the fields in the provided account object. The account 495 | /// object may contain a new account name for this account. 496 | pub fn update_account(&mut self, name: &str, new_account: &Account) -> Result<(), UpmError> { 497 | // Check for name collision 498 | if name != new_account.name && self.contains(&new_account.name) { 499 | return Err(UpmError::DuplicateAccountName(new_account.name.clone())); 500 | } 501 | 502 | // Update account 503 | if let Some(account) = self.account_mut(name) { 504 | account.name = new_account.name.clone(); 505 | account.user = new_account.user.clone(); 506 | account.password = new_account.password.clone(); 507 | account.url = new_account.url.clone(); 508 | account.notes = new_account.notes.clone(); 509 | } 510 | Ok(()) 511 | } 512 | 513 | /// Add a copy of the provided account object to the database as a new account. 514 | pub fn add_account(&mut self, new_account: &Account) -> Result<(), UpmError> { 515 | // Check for name collision 516 | if self.contains(&new_account.name) { 517 | return Err(UpmError::DuplicateAccountName(new_account.name.clone())); 518 | } 519 | 520 | // Add account 521 | self.accounts.push(new_account.clone()); 522 | Ok(()) 523 | } 524 | 525 | /// Delete the specified account from the database. 526 | pub fn delete_account(&mut self, name: &str) { 527 | self.accounts.retain(|ref a| a.name != name); 528 | } 529 | 530 | /// Return true if this database has a remote sync repository configured; otherwise return 531 | /// false. 532 | pub fn has_remote(&self) -> bool { 533 | !self.sync_url.is_empty() 534 | } 535 | 536 | /// Validate that the provided path is valid Unicode and has a final component. After this 537 | /// validation, path.to_str().unwrap() and path.file_name().unwrap() may be safely used. 538 | fn validate_path>(path: &P) -> Result<(), UpmError> { 539 | // Only allow paths that are valid Unicode. This allows us to safely unwrap() the path's 540 | // to_str() later, instead of handling a potential encoding issue each time. 541 | if path.as_ref().to_str().is_none() { 542 | return Err(UpmError::PathNotUnicode( 543 | path.as_ref().to_string_lossy().into_owned(), 544 | )); 545 | } 546 | 547 | // Only allow paths that contain a final component, which is assumed to be the database 548 | // file. This allows us to safely unwrap() the path's file_name() later, instead of 549 | // handling this error each time. 550 | if path.as_ref().file_name().is_none() { 551 | return Err(UpmError::InvalidFilename); 552 | } 553 | Ok(()) 554 | } 555 | 556 | /// Set the path of the local database to the specified path. 557 | pub fn set_path>(&mut self, path: &P) -> Result<(), UpmError> { 558 | Self::validate_path(path)?; 559 | self.path = Some(path.as_ref().to_path_buf()); 560 | Ok(()) 561 | } 562 | 563 | /// Return the path to the local database, if known. 564 | pub fn path(&self) -> Option<&Path> { 565 | match &self.path { 566 | &Some(ref p) => Some(p.as_path()), 567 | &None => None, 568 | } 569 | } 570 | 571 | /// Return the name of the database, if available. The name is the final path component of the 572 | /// database in the local filesystem. 573 | pub fn name(&self) -> Option<&str> { 574 | match self.path { 575 | // These unwrap()'s are safe thanks to validation in set_path(). 576 | Some(ref p) => Some(p.file_name().unwrap().to_str().unwrap()), 577 | None => None, 578 | } 579 | } 580 | 581 | /// Return the name of a database that is represented by the provided filesystem path. 582 | pub fn path_to_name>(path: &P) -> Result<&str, UpmError> { 583 | Self::validate_path(path)?; 584 | // These unwrap()'s are safe thanks to validate_path(). 585 | Ok(path.as_ref().file_name().unwrap().to_str().unwrap()) 586 | } 587 | 588 | /// Set the password used to encrypt this database. 589 | pub fn set_password>(&mut self, password: &P) { 590 | self.password = Some(password.as_ref().to_owned()); 591 | } 592 | 593 | /// Retrieve the password used to encrypt and decrypt this database. 594 | pub fn password(&self) -> Option<&str> { 595 | match &self.password { 596 | &Some(ref p) => Some(p.as_str()), 597 | &None => None, 598 | } 599 | } 600 | 601 | /// Mark the database as being synchronized with the remote sync repository. This is only 602 | /// valid for 5 minutes. 603 | pub fn set_synced(&mut self) { 604 | self.last_synced = Some(Instant::now()); 605 | } 606 | 607 | /// Mark the database as not being synchronized with the remote sync repository. 608 | pub fn clear_synced(&mut self) { 609 | self.last_synced = None; 610 | } 611 | 612 | /// Return true if the database is synchronized with the remote sync repository; otherwise 613 | /// return false. 614 | pub fn is_synced(&self) -> bool { 615 | match self.last_synced { 616 | Some(t) => t.elapsed() < Duration::from_secs(SYNC_VALIDITY_SECS), 617 | None => false, 618 | } 619 | } 620 | } 621 | 622 | impl fmt::Display for Database { 623 | /// Print basic information about this database. 624 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 625 | write!( 626 | f, 627 | "Database(rev={},url={},cred={},count={})", 628 | self.sync_revision, 629 | self.sync_url, 630 | self.sync_credentials, 631 | self.accounts.len() 632 | ) 633 | } 634 | } 635 | 636 | #[cfg(test)] 637 | mod tests { 638 | use super::*; 639 | 640 | #[test] 641 | fn test_flatpack() { 642 | const RECORD_0: &str = "hello"; 643 | const RECORD_1: u32 = 0; 644 | const RECORD_2: u32 = 0x100; 645 | const RECORD_3: u32 = 0xFFFFFFFF; 646 | #[cfg_attr(rustfmt, rustfmt_skip)] 647 | const RECORD_4: &[u8] = &[ 648 | 0xCE, 0xB3, 0xCE, 0xBB, 0xCF, 0x8E, 0xCF, 0x83, 649 | 0xCF, 0x83, 0xCE, 0xB1 650 | ]; 651 | const RECORD_5: u32 = 0; 652 | const RECORD_6: u32 = 0x100; 653 | const RECORD_7: u32 = 0xFFFFFFFF; 654 | const RECORD_8: &str = "goodbye"; 655 | 656 | // Test flatpack encoding 657 | let mut flatpack = FlatpackWriter::new(); 658 | flatpack.put_string(RECORD_0).unwrap(); 659 | flatpack.put_u32(RECORD_1).unwrap(); 660 | flatpack.put_u32(RECORD_2).unwrap(); 661 | flatpack.put_u32(RECORD_3).unwrap(); 662 | flatpack.put_bytes(RECORD_4).unwrap(); 663 | flatpack.put_u32(RECORD_5).unwrap(); 664 | flatpack.put_u32(RECORD_6).unwrap(); 665 | flatpack.put_u32(RECORD_7).unwrap(); 666 | flatpack.put_string(RECORD_8).unwrap(); 667 | let buffer = flatpack.buffer; 668 | 669 | // Test flatpack decoding 670 | let mut parser = FlatpackParser::new(buffer); 671 | assert!(parser.eof() == false); 672 | assert_matches!(parser.next(), Some(Ok(ref s)) if s == RECORD_0 ); 673 | assert!(parser.eof() == false); 674 | assert_matches!(parser.take3(), 675 | Ok((ref a, ref b, ref c)) if 676 | *a == format!("{}", RECORD_1) && 677 | *b == format!("{}", RECORD_2) && 678 | *c == format!("{}", RECORD_3) 679 | ); 680 | assert!(parser.eof() == false); 681 | assert_matches!(parser.take5(), 682 | Ok((ref a, ref b, ref c, ref d, ref e)) if 683 | (*a).as_bytes() == RECORD_4 && 684 | *b == format!("{}", RECORD_5) && 685 | *c == format!("{}", RECORD_6) && 686 | *d == format!("{}", RECORD_7) && 687 | *e == RECORD_8 688 | ); 689 | assert!(parser.eof()); 690 | } 691 | 692 | #[test] 693 | fn test_account_ordering() { 694 | const UNORDERED_NAMES: [&str; 5] = ["Marlin", "zebra", "Aardvark", "lark", "tiger"]; 695 | const ORDERED_NAMES: [&str; 5] = ["Aardvark", "lark", "Marlin", "tiger", "zebra"]; 696 | let mut accounts: Vec = vec![]; 697 | for name in UNORDERED_NAMES.iter() { 698 | accounts.push(Account { 699 | name: String::from(*name), 700 | user: String::from("user"), 701 | password: String::from("password"), 702 | url: String::from("url"), 703 | notes: String::from("notes"), 704 | }); 705 | } 706 | accounts.sort(); 707 | let names: Vec = accounts.iter().map(|a| a.name.clone()).collect(); 708 | assert_eq!(names.as_slice(), ORDERED_NAMES); 709 | } 710 | 711 | const PASSWORD: &str = "xyzzy"; 712 | const INCORRECT_PASSWORD: &str = "frobozz"; 713 | 714 | /// This is a small database encrypted with the above password. 715 | #[cfg_attr(rustfmt, rustfmt_skip)] 716 | const DATABASE_BYTES: &[u8] = &[ 717 | 0x55, 0x50, 0x4D, 0x03, 0x35, 0xB3, 0x66, 0xE2, 718 | 0xF5, 0x28, 0xBF, 0x3E, 0x0E, 0xF5, 0x4D, 0xD8, 719 | 0x47, 0x6B, 0xC2, 0x4E, 0xA0, 0xA0, 0x47, 0x02, 720 | 0x20, 0x25, 0xD8, 0xDB, 0x01, 0x41, 0xB2, 0x06, 721 | 0xE2, 0xB1, 0x50, 0x93, 0xC1, 0x26, 0x01, 0xE9, 722 | 0xA0, 0x96, 0xFA, 0xC7, 0x0B, 0xE7, 0x80, 0x4F, 723 | 0x05, 0x4E, 0xE7, 0x76, 0x4F, 0xC3, 0x42, 0xAC, 724 | 0x76, 0x81, 0x27, 0x8B 725 | ]; 726 | 727 | fn assert_accounts(database: &Database, expected_accounts: &[&str]) { 728 | let mut accounts: Vec = database.accounts.iter().map(|a| a.name.clone()).collect(); 729 | accounts.sort(); 730 | let mut expected_accounts: Vec<&str> = expected_accounts.to_vec(); 731 | expected_accounts.sort(); 732 | if accounts != expected_accounts { 733 | panic!("expected: {:?} received: {:?}", expected_accounts, accounts); 734 | } 735 | } 736 | 737 | #[test] 738 | fn test_database() { 739 | // Load a database with an incorrect password 740 | let result = Database::load_from_bytes(DATABASE_BYTES, INCORRECT_PASSWORD); 741 | assert_matches!(result, Err(_)); 742 | 743 | // Load a database 744 | let result = Database::load_from_bytes(DATABASE_BYTES, PASSWORD); 745 | assert_matches!(result, Ok(_)); 746 | let mut database = result.unwrap(); 747 | 748 | // Verify data 749 | assert_eq!(database.sync_revision, 1); 750 | assert_matches!(database.password, Some(ref p) if p == PASSWORD); 751 | assert_eq!(database.accounts.len(), 1); 752 | assert_eq!(database.accounts[0].name, "acct"); 753 | assert_eq!(database.accounts[0].user, "user"); 754 | assert_eq!(database.accounts[0].password, "pass"); 755 | 756 | // Test account()/account_mut()/contains() 757 | assert_matches!(database.account("noacct"), None); 758 | assert_matches!(database.account_mut("noacct"), None); 759 | assert_matches!(database.account("acct"), Some(_)); 760 | assert_matches!(database.account_mut("acct"), Some(_)); 761 | assert_eq!(database.account("acct").unwrap().name, "acct"); 762 | assert_eq!(database.account_mut("acct").unwrap().name, "acct"); 763 | assert!(database.contains("acct")); 764 | assert!(database.contains("noacct") == false); 765 | 766 | // Add, modify, and delete accounts. 767 | assert_accounts(&database, &["acct"]); 768 | let result = database.add_account(&Account { 769 | name: String::from("acct2"), 770 | user: String::from("user2"), 771 | password: String::from("pass2"), 772 | url: String::from(""), 773 | notes: String::from(""), 774 | }); 775 | assert_matches!(result, Ok(())); 776 | assert_accounts(&database, &["acct", "acct2"]); 777 | let result = database.add_account(&Account { 778 | name: String::from("acct3"), 779 | user: String::from("user3"), 780 | password: String::from("pass3"), 781 | url: String::from(""), 782 | notes: String::from(""), 783 | }); 784 | assert_matches!(result, Ok(())); 785 | assert_accounts(&database, &["acct", "acct2", "acct3"]); 786 | let result = database.update_account( 787 | "acct", 788 | &Account { 789 | name: String::from("acct1"), 790 | user: String::from("user1"), 791 | password: String::from("pass1"), 792 | url: String::from(""), 793 | notes: String::from(""), 794 | }, 795 | ); 796 | assert_matches!(result, Ok(())); 797 | assert_accounts(&database, &["acct1", "acct2", "acct3"]); 798 | database.delete_account("acct2"); 799 | assert_accounts(&database, &["acct1", "acct3"]); 800 | 801 | // Confirm that duplicate account names cannot be created. 802 | let result = database.update_account( 803 | "acct1", 804 | &Account { 805 | name: String::from("acct3"), 806 | user: String::from("user1"), 807 | password: String::from("pass1"), 808 | url: String::from(""), 809 | notes: String::from(""), 810 | }, 811 | ); 812 | assert_matches!(result, Err(UpmError::DuplicateAccountName(ref n)) if n == "acct3"); 813 | let result = database.add_account(&Account { 814 | name: String::from("acct1"), 815 | user: String::from("user1"), 816 | password: String::from("pass1"), 817 | url: String::from(""), 818 | notes: String::from(""), 819 | }); 820 | assert_matches!(result, Err(UpmError::DuplicateAccountName(ref n)) if n == "acct1"); 821 | 822 | // Save the database 823 | let result = database.save_to_bytes(PASSWORD); 824 | assert_matches!(result, Ok(_)); 825 | let bytes = result.unwrap(); 826 | 827 | // Re-load the database 828 | let result = Database::load_from_bytes(&bytes, INCORRECT_PASSWORD); 829 | assert_matches!(result, Err(_)); 830 | let result = Database::load_from_bytes(&bytes, PASSWORD); 831 | assert_matches!(result, Ok(_)); 832 | let database = result.unwrap(); 833 | 834 | // Verify data 835 | assert_accounts(&database, &["acct1", "acct3"]); 836 | assert_eq!(database.account("acct1").unwrap().user, "user1"); 837 | assert_eq!(database.account("acct1").unwrap().password, "pass1"); 838 | assert_eq!(database.account("acct3").unwrap().user, "user3"); 839 | assert_eq!(database.account("acct3").unwrap().password, "pass3"); 840 | } 841 | 842 | #[cfg_attr(rustfmt, rustfmt_skip)] 843 | const VALID_UTF8: &[u8] = &[ 844 | 0xCE, 0xB3, 0xCE, 0xBB, 0xCF, 0x8E, 0xCF, 0x83, 845 | 0xCF, 0x83, 0xCE, 0xB1 846 | ]; 847 | 848 | #[test] 849 | fn test_validate_path() { 850 | assert_matches!(Database::validate_path(&""), Err(UpmError::InvalidFilename)); 851 | assert_matches!(Database::validate_path(&"file"), Ok(())); 852 | assert_matches!(Database::validate_path(&"/path/to/file"), Ok(())); 853 | assert_matches!(Database::validate_path(&"/path/to/dir/"), Ok(())); 854 | assert_matches!( 855 | Database::validate_path(&PathBuf::from( 856 | String::from_utf8(VALID_UTF8.to_vec()).unwrap() 857 | )), 858 | Ok(()) 859 | ); 860 | // It's not obvious how to test paths with invalid Unicode encodings to make sure they 861 | // result in UpmError::PathNotUnicode. Such a test would likely work differently on 862 | // different platforms, due to differences in the OsStr implementations. 863 | } 864 | } 865 | -------------------------------------------------------------------------------- /src/bin/tupm/ui.rs: -------------------------------------------------------------------------------- 1 | //! User interface components for the Terminal Universal Password Manager. 2 | 3 | extern crate clap; 4 | extern crate upm; 5 | 6 | use cursive; 7 | use cursive::align::HAlign; 8 | use cursive::event::Event::{Char, CtrlChar}; 9 | use cursive::event::Key; 10 | use cursive::menu::MenuItem; 11 | use cursive::menu::MenuTree; 12 | use cursive::view::*; 13 | use cursive::views::*; 14 | use cursive::Cursive; 15 | use std::cell::Cell; 16 | use std::cell::RefCell; 17 | use std::ops::Deref; 18 | use std::rc::Rc; 19 | use std::sync::mpsc; 20 | use tupm::clipboard::clipboard_copy; 21 | use tupm::controller; 22 | use upm::database::{Account, Database}; 23 | 24 | // View ids. These are used to reference specific views within the Cursive view tree. 25 | const VIEW_ID_SELECT: &'static str = "select"; 26 | const VIEW_ID_DETAIL: &'static str = "detail"; 27 | const VIEW_ID_FILTER: &'static str = "filter"; 28 | const VIEW_ID_REVISION: &'static str = "revision"; 29 | const VIEW_ID_MODIFIED: &'static str = "modified"; 30 | const VIEW_ID_COUNT: &'static str = "count"; 31 | const VIEW_ID_STATUSLINE: &'static str = "statusline"; 32 | const VIEW_ID_EDIT: &'static str = "edit"; 33 | const VIEW_ID_MODAL: &'static str = "modal"; 34 | const VIEW_ID_INPUT: &'static str = "input"; 35 | 36 | // Human-readable field labels 37 | const FIELD_NAME: &'static str = "Account"; 38 | const FIELD_USER: &'static str = "Username"; 39 | const FIELD_PASSWORD: &'static str = "Password"; 40 | const FIELD_URL: &'static str = "URL"; 41 | const FIELD_NOTES: &'static str = "Notes"; 42 | 43 | /// Describe a specific account field. 44 | struct Field { 45 | name: &'static str, 46 | secret: bool, 47 | multiline: bool, 48 | } 49 | 50 | /// Provide a description of each account field. 51 | const FIELDS: [Field; 5] = [ 52 | Field { 53 | name: FIELD_NAME, 54 | secret: false, 55 | multiline: false, 56 | }, 57 | Field { 58 | name: FIELD_USER, 59 | secret: false, 60 | multiline: false, 61 | }, 62 | Field { 63 | name: FIELD_PASSWORD, 64 | secret: true, 65 | multiline: false, 66 | }, 67 | Field { 68 | name: FIELD_URL, 69 | secret: false, 70 | multiline: false, 71 | }, 72 | Field { 73 | name: FIELD_NOTES, 74 | secret: false, 75 | multiline: true, 76 | }, 77 | ]; 78 | 79 | //////////////////////////////////////////////////////////////////////// 80 | // KeyOverrideView 81 | //////////////////////////////////////////////////////////////////////// 82 | 83 | use cursive::event::{Callback, Event, EventResult}; 84 | use cursive::view::{View, ViewWrapper}; 85 | use std::collections::HashMap; 86 | use std::collections::HashSet; 87 | 88 | /// This view works similarly to the KeyEventView, but the logic has been reversed -- instead of 89 | /// handling only events in our callback list that the child has ignored, we always handle events 90 | /// in the callback list without offering them to the child at all. Also, an ignored event list is 91 | /// available to simply shield the child from receiving a particular event. 92 | pub struct KeyOverrideView { 93 | content: T, 94 | config: KeyConfig, 95 | } 96 | 97 | impl KeyOverrideView { 98 | /// Create a new KeyOverrideView which wraps the provided view. 99 | pub fn new(view: T) -> Self { 100 | KeyOverrideView { 101 | content: view, 102 | config: KeyConfig { 103 | callbacks: Rc::new(RefCell::new(HashMap::new())), 104 | ignored: Rc::new(RefCell::new(HashSet::new())), 105 | }, 106 | } 107 | } 108 | 109 | /// Add an event which should be ignored instead of passed to the interior view. 110 | pub fn ignore>(mut self, event: E) -> Self { 111 | // Proxy to KeyConfig 112 | self.config = self.config.ignore(event); 113 | self 114 | } 115 | 116 | /// Register a closure to handle an event, instead of passing the event to the interior view. 117 | pub fn register>(mut self, event: E, cb: F) -> Self 118 | where 119 | F: Fn(&mut Cursive) + 'static, 120 | { 121 | // Proxy to KeyConfig 122 | self.config = self.config.register(event, cb); 123 | self 124 | } 125 | 126 | /// Register a callback to handle an event, instead of passing the event to the interior view. 127 | pub fn register_callback>(mut self, event: E, cb: Callback) -> Self { 128 | // Proxy to KeyConfig 129 | self.config = self.config.register_callback(event, cb); 130 | self 131 | } 132 | 133 | /// Return a KeyConfig struct for this view, which allows changing the event handling 134 | /// configuration after the view has been integrated into the Cursive view tree. 135 | pub fn get_config(&self) -> KeyConfig { 136 | KeyConfig { 137 | callbacks: self.config.callbacks.clone(), 138 | ignored: self.config.ignored.clone(), 139 | } 140 | } 141 | } 142 | 143 | impl ViewWrapper for KeyOverrideView { 144 | wrap_impl!(self.content: T); 145 | 146 | /// Wrap the on_event method to intercept events before they are delivered to the interior 147 | /// view. 148 | fn wrap_on_event(&mut self, event: Event) -> EventResult { 149 | if self.config.ignored.borrow().contains(&event) { 150 | EventResult::Ignored 151 | } else { 152 | match self.config.callbacks.borrow().get(&event) { 153 | None => self.content.on_event(event.clone()), 154 | Some(cb) => EventResult::Consumed(Some(cb.clone())), 155 | } 156 | } 157 | } 158 | } 159 | 160 | /// KeyConfig allows callers a means of configuring keyboard shortcuts even after the view wrapper 161 | /// has been installed in the Cursive view tree. 162 | #[derive(Clone)] 163 | pub struct KeyConfig { 164 | callbacks: Rc>>, 165 | ignored: Rc>>, 166 | } 167 | 168 | impl KeyConfig { 169 | /// Add an event which should be ignored instead of passed to the interior view. 170 | #[allow(dead_code)] 171 | pub fn ignore>(self, event: E) -> Self { 172 | self.ignored.borrow_mut().insert(event.into()); 173 | self 174 | } 175 | 176 | /// Register a closure to handle an event, instead of passing the event to the interior view. 177 | #[allow(dead_code)] 178 | pub fn register>(self, event: E, cb: F) -> Self 179 | where 180 | F: Fn(&mut Cursive) + 'static, 181 | { 182 | self.callbacks 183 | .borrow_mut() 184 | .insert(event.into(), Callback::from_fn(cb)); 185 | self 186 | } 187 | 188 | /// Register a callback to handle an event, instead of passing the event to the interior view. 189 | pub fn register_callback>(self, event: E, cb: Callback) -> Self { 190 | self.callbacks.borrow_mut().insert(event.into(), cb); 191 | self 192 | } 193 | } 194 | 195 | //////////////////////////////////////////////////////////////////////// 196 | // AccountSelectView 197 | //////////////////////////////////////////////////////////////////////// 198 | 199 | /// Provide a view for selecting accounts in the database. This view wraps a Cursive SelectView, 200 | /// and supports filtering the list. 201 | pub struct AccountSelectView { 202 | content: SelectView, 203 | database: Rc>, 204 | filter: String, 205 | displayed_accounts: Vec, 206 | } 207 | 208 | impl AccountSelectView { 209 | /// Create a new AccountSelectView representing the accounts in the provided database. 210 | pub fn new(database: Rc>) -> Self { 211 | AccountSelectView { 212 | content: SelectView::::new(), 213 | database, 214 | filter: String::new(), 215 | displayed_accounts: vec![], 216 | } 217 | } 218 | 219 | /// Load accounts from a new database. 220 | pub fn load(&mut self, database: Rc>) { 221 | self.database = database; 222 | self.render(); 223 | } 224 | 225 | /// Render the view by populating the interior SelectView with the relevant accounts. 226 | fn render(&mut self) { 227 | self.clear(); 228 | self.displayed_accounts.clear(); 229 | let database = self.database.borrow(); 230 | for account in database.accounts.iter() { 231 | if self.filter.is_empty() || account.name.contains(&self.filter) { 232 | self.content.add_item(account.name.clone(), account.clone()); 233 | 234 | // Maintain a list of displayed account names since 235 | // Cursive's SelectView doesn't expose these details 236 | // of the data model. 237 | self.displayed_accounts.push(account.name.clone()); 238 | } 239 | } 240 | } 241 | 242 | /// Configure a submit callback. This proxies to the SelectView method. 243 | pub fn set_on_submit(&mut self, cb: F) 244 | where 245 | F: Fn(&mut Cursive, &Account) + 'static, 246 | { 247 | self.content.set_on_submit(cb) 248 | } 249 | 250 | /// Configure a select callback. This proxies to the SelectView method. 251 | pub fn set_on_select(&mut self, cb: F) 252 | where 253 | F: Fn(&mut Cursive, &Account) + 'static, 254 | { 255 | self.content.set_on_select(cb) 256 | } 257 | 258 | /// Return the currently selected account, if any. 259 | pub fn selection(&self) -> Option> { 260 | self.content.selection() 261 | } 262 | 263 | /// Clear the list. 264 | pub fn clear(&mut self) { 265 | self.content.clear(); 266 | } 267 | 268 | /// Filter the account list based on the provided substring filter. 269 | pub fn filter(&mut self, text: &str) { 270 | self.filter = String::from(text); 271 | self.render(); 272 | } 273 | 274 | /// Return the total number of accounts. 275 | pub fn count(&self) -> usize { 276 | self.database.borrow().accounts.len() 277 | } 278 | 279 | /// Return the number of accounts which are being shown. 280 | /// (I.e., accounts that match whatever filter may be in effect.) 281 | pub fn display_count(&self) -> usize { 282 | self.content.len() 283 | } 284 | } 285 | 286 | impl ViewWrapper for AccountSelectView { 287 | wrap_impl!(self.content: SelectView); 288 | } 289 | 290 | //////////////////////////////////////////////////////////////////////// 291 | // AccountEditView 292 | //////////////////////////////////////////////////////////////////////// 293 | 294 | /// This view provides an account edit dialog to create a new account or edit an existing account. 295 | pub struct AccountEditView { 296 | content: LinearLayout, 297 | account: Account, 298 | } 299 | 300 | impl AccountEditView { 301 | /// Create a new AccountEditView. 302 | pub fn new(account: Account) -> Self { 303 | let mut v_layout = LinearLayout::vertical(); 304 | 305 | let field_max = FIELDS.into_iter().map(|f| f.name.len()).max().unwrap(); 306 | let labelify = |name: &str| { 307 | let mut s = String::with_capacity(field_max); 308 | s.push_str(name); 309 | s.push_str(": "); 310 | for _ in 0..(field_max - name.len()) { 311 | s.push(' '); 312 | } 313 | s 314 | }; 315 | 316 | for field in FIELDS.into_iter() { 317 | let id = format!("{}_{}", VIEW_ID_EDIT, field.name); 318 | let mut edit_view = EditView::new(); 319 | edit_view.set_secret(field.secret); 320 | if !field.multiline { 321 | v_layout.add_child( 322 | LinearLayout::horizontal() 323 | .child(TextView::new(labelify(field.name))) 324 | .child(BoxView::new( 325 | SizeConstraint::AtLeast(30), 326 | SizeConstraint::AtMost(1), 327 | edit_view.with_id(id), 328 | )), 329 | ); 330 | } else { 331 | v_layout.add_child( 332 | LinearLayout::vertical() 333 | .child(TextView::new(labelify(field.name))) 334 | .child(BoxView::new( 335 | SizeConstraint::AtLeast(30), 336 | SizeConstraint::Fixed(10), 337 | TextArea::new().with_id(id), 338 | )), 339 | ); 340 | } 341 | } 342 | v_layout.add_child(TextView::new("Ctrl-R: Reveal password")); 343 | v_layout.add_child(TextView::new("Ctrl-X: Apply changes")); 344 | 345 | let mut account_edit = AccountEditView { 346 | content: v_layout, 347 | account: account, 348 | }; 349 | account_edit.load(); 350 | account_edit 351 | } 352 | 353 | /// Provision a new dialog box containing an AccountEditView and some basic handlers. 354 | pub fn show( 355 | cursive: &mut Cursive, 356 | database: Rc>, 357 | controller_tx: mpsc::Sender, 358 | account: Option<&Account>, 359 | ) { 360 | let create: bool; 361 | let account = match account { 362 | Some(account) => { 363 | create = false; 364 | account.clone() 365 | } 366 | None => { 367 | create = true; 368 | Account::new() 369 | } 370 | }; 371 | 372 | let account_edit = AccountEditView::new(account.clone()).with_id(VIEW_ID_EDIT); 373 | let controller_tx_clone = controller_tx.clone(); 374 | let database_clone = database.clone(); 375 | let key_override = KeyOverrideView::new(account_edit) 376 | .register(cursive::event::Event::CtrlChar('r'), |s| { 377 | // reveal password 378 | if let Some(mut account_edit) = s.find_id::(VIEW_ID_EDIT) { 379 | account_edit.reveal_password(); 380 | } 381 | }) 382 | .register(cursive::event::Event::CtrlChar('x'), move |s| { 383 | AccountEditView::apply(s, database_clone.clone(), &controller_tx_clone) 384 | }); 385 | let controller_tx_clone = controller_tx.clone(); 386 | let database_clone = database.clone(); 387 | cursive.add_layer( 388 | Dialog::around(key_override) 389 | .title(if create { 390 | "New account..." 391 | } else { 392 | "Edit account..." 393 | }) 394 | .button("Apply", move |s| { 395 | AccountEditView::apply(s, database_clone.clone(), &controller_tx_clone) 396 | }) 397 | .dismiss_button("Cancel"), 398 | ); 399 | } 400 | 401 | /// Handle the CTRL-R "reveal password" feature. 402 | fn reveal_password(&mut self) { 403 | let id = format!("{}_{}", VIEW_ID_EDIT, FIELD_PASSWORD); 404 | self.find_id(&id, |edit_view: &mut EditView| { 405 | edit_view.set_secret(false); 406 | }); 407 | } 408 | 409 | /// Populate a UI field with a value. 410 | fn put(&mut self, field_name: &str, value: &str) { 411 | let id = format!("{}_{}", VIEW_ID_EDIT, field_name); 412 | if FIELDS 413 | .into_iter() 414 | .any(|f| f.name == field_name && f.multiline) 415 | { 416 | self.find_id(&id, |edit_view: &mut TextArea| edit_view.set_content(value)); 417 | } else { 418 | self.find_id(&id, |edit_view: &mut EditView| edit_view.set_content(value)); 419 | } 420 | } 421 | 422 | /// Retrieve the text from a UI field. 423 | fn get(&mut self, field_name: &str) -> String { 424 | let id = format!("{}_{}", VIEW_ID_EDIT, field_name); 425 | 426 | if FIELDS 427 | .into_iter() 428 | .any(|f| f.name == field_name && f.multiline) 429 | { 430 | match self.find_id(&id, |edit_view: &mut TextArea| { 431 | String::from(edit_view.get_content()) 432 | }) { 433 | Some(x) => x, 434 | None => String::from(""), 435 | } 436 | } else { 437 | match self.find_id(&id, |edit_view: &mut EditView| edit_view.get_content()) { 438 | Some(x) => (*x).clone(), 439 | None => String::from(""), 440 | } 441 | } 442 | } 443 | 444 | /// Load the fields from the contained account object into the UI. 445 | fn load(&mut self) { 446 | let account = self.account.clone(); 447 | self.put(FIELD_NAME, &account.name); 448 | self.put(FIELD_USER, &account.user); 449 | self.put(FIELD_PASSWORD, &account.password); 450 | self.put(FIELD_URL, &account.url); 451 | self.put(FIELD_NOTES, &account.notes); 452 | } 453 | 454 | /// Return an account object representing the current state of the UI fields. 455 | fn current(&mut self) -> Account { 456 | Account { 457 | name: self.get(FIELD_NAME), 458 | user: self.get(FIELD_USER), 459 | password: self.get(FIELD_PASSWORD), 460 | url: self.get(FIELD_URL), 461 | notes: self.get(FIELD_NOTES), 462 | } 463 | } 464 | 465 | /// Update the database with the information contained in the form. 466 | fn apply( 467 | cursive: &mut Cursive, 468 | database: Rc>, 469 | controller_tx: &mpsc::Sender, 470 | ) { 471 | // We can't have references to both the AccountEditView and 472 | // AccountSelectView in the same lexical scope, since it causes 473 | // a BorrowMutError for some reason. So, extract the needed 474 | // information from AccountEditView before updating the database 475 | // contained within AccountSelectView. 476 | let (name, previous, current) = 477 | if let Some(mut account_edit) = cursive.find_id::(VIEW_ID_EDIT) { 478 | ( 479 | account_edit.account.name.clone(), 480 | account_edit.account.clone(), 481 | account_edit.current(), 482 | ) 483 | } else { 484 | return; 485 | }; 486 | 487 | // Check for name collision 488 | if name != current.name && database.borrow().contains(¤t.name) { 489 | cursive.add_layer( 490 | Dialog::around(TextView::new( 491 | "Another account already exists with this name.", 492 | )) 493 | .title("Alert") 494 | .button("OK", |s| { 495 | s.pop_layer(); 496 | }), 497 | ); 498 | return; 499 | } 500 | cursive.screen_mut().pop_layer(); 501 | 502 | // Send the account edit request to the controller 503 | let before = if previous.name.is_empty() { 504 | None 505 | } else { 506 | Some(previous) 507 | }; 508 | controller_tx 509 | .send(controller::Message::AccountEdit(before, Some(current))) 510 | .unwrap(); 511 | } 512 | } 513 | 514 | impl ViewWrapper for AccountEditView { 515 | wrap_impl!(self.content: LinearLayout); 516 | } 517 | 518 | //////////////////////////////////////////////////////////////////////// 519 | // DatabaseEditView 520 | //////////////////////////////////////////////////////////////////////// 521 | 522 | /// Edit the database properties. 523 | pub struct DatabaseEditView { 524 | content: LinearLayout, 525 | url: String, 526 | credentials: String, 527 | } 528 | 529 | impl DatabaseEditView { 530 | /// Create a new DatabaseEditView. 531 | pub fn new(url: &str, credentials: &str) -> Self { 532 | let mut v_layout = LinearLayout::vertical(); 533 | 534 | let id = format!("{}_{}", VIEW_ID_EDIT, "url"); 535 | let mut edit_view = EditView::new(); 536 | edit_view.set_content(url); 537 | v_layout.add_child( 538 | LinearLayout::horizontal() 539 | .child(TextView::new("Sync URL: ")) 540 | .child(BoxView::new( 541 | SizeConstraint::AtLeast(50), 542 | SizeConstraint::AtMost(1), 543 | edit_view.with_id(id), 544 | )), 545 | ); 546 | 547 | let id = format!("{}_{}", VIEW_ID_EDIT, "credentials"); 548 | let mut edit_view = EditView::new(); 549 | edit_view.set_content(credentials); 550 | v_layout.add_child( 551 | LinearLayout::horizontal() 552 | .child(TextView::new("Sync credentials: ")) 553 | .child(BoxView::new( 554 | SizeConstraint::AtLeast(50), 555 | SizeConstraint::AtMost(1), 556 | edit_view.with_id(id), 557 | )), 558 | ); 559 | 560 | v_layout.add_child(TextView::new("Ctrl-X: Apply changes")); 561 | v_layout.add_child(TextView::new( 562 | "The sync credentials must exactly match the name of an \ 563 | account which holds the HTTP Basic Authentication username \ 564 | and password.", 565 | )); 566 | 567 | DatabaseEditView { 568 | content: v_layout, 569 | url: String::from(url), 570 | credentials: String::from(credentials), 571 | } 572 | } 573 | 574 | /// Provision a new dialog box containing a DatabaseEditView and some basic handlers. 575 | pub fn show( 576 | cursive: &mut Cursive, 577 | database: Rc>, 578 | controller_tx: mpsc::Sender, 579 | ) { 580 | let database_edit = DatabaseEditView::new( 581 | &database.borrow().sync_url, 582 | &database.borrow().sync_credentials, 583 | ) 584 | .with_id(VIEW_ID_EDIT); 585 | let controller_tx_clone = controller_tx.clone(); 586 | let key_override = KeyOverrideView::new(database_edit).register( 587 | cursive::event::Event::CtrlChar('x'), 588 | move |s| { 589 | DatabaseEditView::apply(s, &controller_tx_clone); 590 | }, 591 | ); 592 | let controller_tx_clone = controller_tx.clone(); 593 | cursive.add_layer( 594 | Dialog::around(key_override) 595 | .title("Database Properties...") 596 | .button("Apply", move |s| { 597 | DatabaseEditView::apply(s, &controller_tx_clone) 598 | }) 599 | .dismiss_button("Cancel"), 600 | ); 601 | } 602 | 603 | /// Record the (potentially edited) UI fields into the database. 604 | fn apply(cursive: &mut Cursive, controller_tx: &mpsc::Sender) { 605 | let (old_url, old_credentials) = { 606 | let database_edit = cursive.find_id::(VIEW_ID_EDIT).unwrap(); 607 | (database_edit.url.clone(), database_edit.credentials.clone()) 608 | }; 609 | 610 | let id = format!("{}_{}", VIEW_ID_EDIT, "url"); 611 | let new_url = cursive.find_id::(&id).unwrap().get_content(); 612 | 613 | let id = format!("{}_{}", VIEW_ID_EDIT, "credentials"); 614 | let new_credentials = cursive.find_id::(&id).unwrap().get_content(); 615 | 616 | if (&old_url, &old_credentials) != (&new_url, &new_credentials) { 617 | controller_tx 618 | .send(controller::Message::DatabaseEdit( 619 | (*new_url).clone(), 620 | (*new_credentials).clone(), 621 | )) 622 | .unwrap(); 623 | } 624 | cursive.screen_mut().pop_layer(); 625 | } 626 | } 627 | 628 | impl ViewWrapper for DatabaseEditView { 629 | wrap_impl!(self.content: LinearLayout); 630 | } 631 | 632 | //////////////////////////////////////////////////////////////////////// 633 | // Ui 634 | //////////////////////////////////////////////////////////////////////// 635 | 636 | /// The UI maintains a message queue consisting of zero or more of these messages. Other 637 | /// components can add messages to the queue, and the UI will process them in order. 638 | #[derive(Debug)] 639 | pub enum UiMessage { 640 | UpdateStatus, 641 | ShowAccountEdit(Option), 642 | ShowDatabaseEdit, 643 | RequireSync, 644 | ChangePassword, 645 | Refresh, 646 | } 647 | 648 | /// Provide the user interface. This struct owns the Cursive instance and all data needed to 649 | /// handle user interaction. 650 | pub struct Ui { 651 | cursive: Cursive, 652 | ui_rx: mpsc::Receiver, 653 | ui_tx: mpsc::Sender, 654 | controller_tx: mpsc::Sender, 655 | database: Rc>, 656 | } 657 | 658 | impl Ui { 659 | /// Create a new Ui object. The provided `mpsc` sender will be used by the UI to send messages 660 | /// to the controller. 661 | pub fn new(controller_tx: mpsc::Sender) -> Ui { 662 | let (ui_tx, ui_rx) = mpsc::channel::(); 663 | let mut ui = Ui { 664 | cursive: Cursive::default(), 665 | ui_tx, 666 | ui_rx, 667 | controller_tx, 668 | database: Rc::new(RefCell::new(Database::new())), 669 | }; 670 | 671 | //////////////////////////////////////////////////////////// 672 | // Construct the Cursive view hierarchy for our user interface. 673 | //////////////////////////////////////////////////////////// 674 | 675 | let mut account_list = AccountSelectView::new(ui.database.clone()); 676 | 677 | let account_detail = TextView::new("").with_id(VIEW_ID_DETAIL).scrollable(); 678 | 679 | let account_detail_panel = Panel::new(BoxView::new( 680 | // Hack to make the detail panel consume the rest of the horizontal space. Full wasn't 681 | // working when the SelectView had a large number of accounts and scrollbar was 682 | // present. 683 | SizeConstraint::AtLeast(500), 684 | SizeConstraint::Free, 685 | account_detail, 686 | )); 687 | 688 | let ui_tx_clone = ui.ui_tx.clone(); 689 | account_list.set_on_select(move |s, account| { 690 | s.call_on_id(VIEW_ID_DETAIL, |detail: &mut TextView| { 691 | detail.set_content(render_account_text(account, false)); 692 | ui_tx_clone.send(UiMessage::UpdateStatus).unwrap(); 693 | }); 694 | }); 695 | 696 | let ui_tx_clone = ui.ui_tx.clone(); 697 | let database_clone = ui.database.clone(); 698 | account_list.set_on_submit(move |_, account| { 699 | let account = account.clone(); 700 | let ui_tx_clone2 = ui_tx_clone.clone(); 701 | if sync_guard(&database_clone.borrow(), &ui_tx_clone2) { 702 | return; 703 | } else { 704 | ui_tx_clone2 705 | .send(UiMessage::ShowAccountEdit(Some(account.clone()))) 706 | .unwrap(); 707 | } 708 | }); 709 | 710 | let account_list_key_override = 711 | KeyOverrideView::new(account_list.with_id(VIEW_ID_SELECT)).ignore('/'); 712 | let account_list_keys = account_list_key_override.get_config(); 713 | 714 | let account_list_panel = Panel::new(BoxView::new( 715 | SizeConstraint::AtLeast(20), 716 | SizeConstraint::Free, 717 | account_list_key_override, 718 | )); 719 | 720 | let mut h_layout = LinearLayout::horizontal(); 721 | h_layout.add_child(account_list_panel); 722 | h_layout.add_child(account_detail_panel); 723 | 724 | let body = BoxView::new(SizeConstraint::Full, SizeConstraint::Full, h_layout); 725 | 726 | let ui_tx_clone = ui.ui_tx.clone(); 727 | let filter_edit = EditView::new().on_edit(move |s, text, _| { 728 | let details = match s.find_id::(VIEW_ID_SELECT) { 729 | Some(mut account_list) => { 730 | account_list.filter(text); 731 | account_list 732 | .selection() 733 | .map(|a| render_account_text(&a, false)) 734 | } 735 | None => None, 736 | }; 737 | match s.find_id::(VIEW_ID_DETAIL) { 738 | Some(mut account_detail) => { 739 | match details { 740 | Some(details) => account_detail.set_content(details), 741 | None => account_detail.set_content(""), 742 | }; 743 | } 744 | None => {} 745 | }; 746 | ui_tx_clone.send(UiMessage::UpdateStatus).unwrap(); 747 | }); 748 | let filter_edit = filter_edit.with_id(VIEW_ID_FILTER); 749 | 750 | let revision_text = TextView::new("").with_id(VIEW_ID_REVISION); 751 | let modified_text = TextView::new("").with_id(VIEW_ID_MODIFIED); 752 | let count_text = TextView::new("").with_id(VIEW_ID_COUNT); 753 | let statusline_text = TextView::new("").with_id(VIEW_ID_STATUSLINE); 754 | 755 | let help_text = TextView::new("Press escape or \\ for menu."); 756 | let status_layout = LinearLayout::horizontal() 757 | .child(TextView::new("filter: ")) 758 | .child(BoxView::new( 759 | SizeConstraint::AtLeast(14), 760 | SizeConstraint::Free, 761 | filter_edit, 762 | )) 763 | .weight(10) 764 | .child(TextView::new(" | ")) 765 | .child(revision_text) 766 | .child(modified_text) 767 | .child(TextView::new(" | ")) 768 | .child(count_text); 769 | let status_layout = LinearLayout::vertical() 770 | .child(status_layout) 771 | .child(help_text) 772 | .child(statusline_text); 773 | let status_box = BoxView::new( 774 | SizeConstraint::Full, 775 | SizeConstraint::Fixed(4), 776 | status_layout, 777 | ); 778 | 779 | let title = TextView::new("Terminal universal password manager").h_align(HAlign::Center); 780 | let layout = LinearLayout::vertical() 781 | .child(title) 782 | .child(body) 783 | .weight(100) 784 | .child(status_box); 785 | let main_dialog = BoxView::new(SizeConstraint::Full, SizeConstraint::Full, layout); 786 | 787 | //////////////////////////////////////////////////////////// 788 | // Callbacks 789 | //////////////////////////////////////////////////////////// 790 | 791 | // Even though these are lightweight clones, it is still a shame that we need to go through 792 | // this awkward dance to use these items within closures. 793 | let controller_tx_clone1 = ui.controller_tx.clone(); 794 | let controller_tx_clone2 = ui.controller_tx.clone(); 795 | let controller_tx_clone3 = ui.controller_tx.clone(); 796 | let ui_tx_clone1 = ui.ui_tx.clone(); 797 | let ui_tx_clone2 = ui.ui_tx.clone(); 798 | let ui_tx_clone3 = ui.ui_tx.clone(); 799 | let ui_tx_clone4 = ui.ui_tx.clone(); 800 | let ui_tx_clone5 = ui.ui_tx.clone(); 801 | let database_clone1 = ui.database.clone(); 802 | let database_clone2 = ui.database.clone(); 803 | 804 | let do_focus_filter = Callback::from_fn(|s| { 805 | let _ = s.focus_id(VIEW_ID_FILTER); 806 | }); 807 | 808 | let do_clipboard_copy_username = Callback::from_fn(|s| { 809 | match selected_account(s) { 810 | Some(account) => { 811 | match clipboard_copy(account.user.as_str()) { 812 | Ok(_) => (), 813 | Err(e) => { 814 | let dialog = Dialog::info(e).title("Error while copying to clipboard:"); 815 | s.add_layer(dialog); 816 | } 817 | }; 818 | } 819 | None => {} 820 | }; 821 | }); 822 | 823 | let do_clipboard_copy_password = Callback::from_fn(|s| { 824 | match selected_account(s) { 825 | Some(account) => { 826 | match clipboard_copy(account.password.as_str()) { 827 | Ok(_) => (), 828 | Err(e) => { 829 | let dialog = Dialog::info(e).title("Error while copying to clipboard:"); 830 | s.add_layer(dialog); 831 | } 832 | }; 833 | } 834 | None => {} 835 | }; 836 | }); 837 | 838 | let do_reveal_password = Callback::from_fn(|s| { 839 | let account = match selected_account(s) { 840 | Some(account) => account, 841 | None => return, 842 | }; 843 | match s.find_id::(VIEW_ID_DETAIL) { 844 | Some(mut detail) => detail.set_content(render_account_text(&account, true)), 845 | None => {} 846 | }; 847 | }); 848 | 849 | let do_new_account = Callback::from_fn(move |_| { 850 | if sync_guard(&database_clone1.borrow(), &ui_tx_clone1) { 851 | return; 852 | } else { 853 | ui_tx_clone1.send(UiMessage::ShowAccountEdit(None)).unwrap(); 854 | } 855 | }); 856 | 857 | let do_delete_account = Callback::from_fn(move |s| { 858 | if let Some(account) = selected_account(s) { 859 | if sync_guard(&database_clone2.borrow(), &ui_tx_clone2) { 860 | return; 861 | } 862 | let controller_tx_clone = controller_tx_clone1.clone(); 863 | s.add_layer( 864 | Dialog::around(TextView::new(format!( 865 | "Really delete account \"{}\"?", 866 | account.name 867 | ))) 868 | .title("Confirm") 869 | .button("No", |s| { 870 | s.pop_layer(); 871 | }) 872 | .button("Yes", move |s| { 873 | controller_tx_clone 874 | .send(controller::Message::AccountEdit( 875 | Some((*account).clone()), 876 | None, 877 | )) 878 | .unwrap(); 879 | s.pop_layer(); 880 | }), 881 | ); 882 | } 883 | }); 884 | 885 | let do_sync = Callback::from_fn(move |_| { 886 | controller_tx_clone2 887 | .send(controller::Message::Sync) 888 | .unwrap(); 889 | }); 890 | 891 | let do_edit_database = Callback::from_fn(move |_| { 892 | ui_tx_clone3.send(UiMessage::ShowDatabaseEdit).unwrap(); 893 | }); 894 | 895 | let do_change_password = Callback::from_fn(move |_| { 896 | ui_tx_clone4.send(UiMessage::ChangePassword).unwrap(); 897 | }); 898 | 899 | let do_quit = Callback::from_fn(move |_| { 900 | controller_tx_clone3 901 | .send(controller::Message::Quit) 902 | .unwrap(); 903 | }); 904 | 905 | let do_refresh = Callback::from_fn(move |_| { 906 | ui_tx_clone5.send(UiMessage::Refresh).unwrap(); 907 | }); 908 | 909 | //////////////////////////////////////////////////////////// 910 | // Menu bar 911 | //////////////////////////////////////////////////////////// 912 | 913 | // We don't use the more idiomatic builder syntax for 914 | // constructing menus, but instead manually build the data 915 | // structure of each MenuTree. This allows us to provide 916 | // Callback structs instead of closures. We use Callback 917 | // structs instead of closures so we can define our callbacks 918 | // above, and reuse them for several bindings (e.g. menu items 919 | // and key shortcuts). 920 | 921 | let mut file_menu = MenuTree::new(); 922 | file_menu.children = vec![MenuItem::Leaf(String::from("Quit ^X"), do_quit.clone())]; 923 | let mut database_menu = MenuTree::new(); 924 | database_menu.children = vec![ 925 | MenuItem::Leaf(String::from("Sync Database ^Y"), do_sync.clone()), 926 | MenuItem::Leaf( 927 | String::from("Edit Database Properties ^K"), 928 | do_edit_database.clone(), 929 | ), 930 | MenuItem::Leaf(String::from("Change Database Password"), do_change_password), 931 | ]; 932 | let mut account_menu = MenuTree::new(); 933 | account_menu.children = vec![ 934 | MenuItem::Leaf(String::from("New Account ^N"), do_new_account.clone()), 935 | MenuItem::Leaf( 936 | String::from("Delete Account ^D"), 937 | do_delete_account.clone(), 938 | ), 939 | MenuItem::Leaf( 940 | String::from("Copy Username ^U"), 941 | do_clipboard_copy_username.clone(), 942 | ), 943 | MenuItem::Leaf( 944 | String::from("Copy Password ^P"), 945 | do_clipboard_copy_password.clone(), 946 | ), 947 | MenuItem::Leaf( 948 | String::from("Reveal Password ^R"), 949 | do_reveal_password.clone(), 950 | ), 951 | ]; 952 | ui.cursive 953 | .menubar() 954 | .add_subtree("File", file_menu) 955 | .add_subtree("Database", database_menu) 956 | .add_subtree("Account", account_menu); 957 | ui.cursive.set_autohide_menu(false); 958 | 959 | //////////////////////////////////////////////////////////// 960 | // Key shortcuts 961 | //////////////////////////////////////////////////////////// 962 | 963 | let main_key_override = KeyOverrideView::new(main_dialog) 964 | // / : Focus the filter edit view 965 | .register_callback(Char('/'), do_focus_filter) 966 | // Ctrl-U: Copy username to clipboard 967 | .register_callback(CtrlChar('u'), do_clipboard_copy_username) 968 | // Ctrl-P: Copy password to clipboard 969 | .register_callback(CtrlChar('p'), do_clipboard_copy_password) 970 | // Ctrl-R: Reveal password 971 | .register_callback(CtrlChar('r'), do_reveal_password) 972 | // Ctrl-N: New account 973 | .register_callback(CtrlChar('n'), do_new_account) 974 | // Ctrl-D/Backspace/Delete: Delete account 975 | .register_callback(CtrlChar('d'), do_delete_account.clone()) 976 | // Ctrl-Y: Sync 977 | .register_callback(CtrlChar('y'), do_sync) 978 | // Ctrl-K: Database Information 979 | .register_callback(CtrlChar('k'), do_edit_database) 980 | // Ctrl-X: Quit 981 | .register_callback(CtrlChar('x'), do_quit) 982 | // Backslash: Menu bar 983 | .register(Char('\\'), |s| s.select_menubar()); 984 | 985 | account_list_keys 986 | .register_callback(Key::Backspace, do_delete_account.clone()) 987 | .register_callback(Key::Del, do_delete_account); 988 | 989 | ui.cursive.add_layer(main_key_override); 990 | 991 | //////////////////////////////////////////////////////////// 992 | // Global key shortcuts 993 | //////////////////////////////////////////////////////////// 994 | 995 | // Escape key: Pop layers, unless the main layer is active, in which case quit. 996 | ui.cursive.add_global_callback(Key::Esc, |s| { 997 | if s.screen().layer_sizes().len() > 1 { 998 | s.pop_layer(); 999 | } else { 1000 | s.select_menubar(); 1001 | } 1002 | }); 1003 | 1004 | // Ctrl-L: Refresh screen 1005 | ui.cursive 1006 | .add_global_callback(CtrlChar('l'), move |s| do_refresh(s)); 1007 | 1008 | ui 1009 | } 1010 | 1011 | /// Load a new database (or an updated version of the existing database) into the UI. 1012 | pub fn set_database(&mut self, database: &Database) { 1013 | *self.database.borrow_mut() = database.clone(); 1014 | match self.cursive.find_id::(VIEW_ID_SELECT) { 1015 | Some(mut account_list) => { 1016 | let previous_selection = account_list.content.selected_id(); 1017 | account_list.load(self.database.clone()); 1018 | // If possible, restore the previous account 1019 | // selection after a new database is loaded. 1020 | match previous_selection { 1021 | Some(previous_selection) => { 1022 | if previous_selection < account_list.content.len() { 1023 | account_list.content.set_selection(previous_selection); 1024 | } 1025 | } 1026 | None => {} 1027 | }; 1028 | } 1029 | _ => {} 1030 | } 1031 | self.update_detail(); 1032 | self.update_status(); 1033 | } 1034 | 1035 | /// Change the current selection to focus on the account as 1036 | /// specified by its name. If no account with that name is present, 1037 | /// then the selection is not changed. 1038 | pub fn focus_account(&mut self, account_name: &str) { 1039 | if let Some(mut account_list) = self.cursive.find_id::(VIEW_ID_SELECT) { 1040 | let mut target_index: Option = None; 1041 | 1042 | for (index, name) in account_list.displayed_accounts.iter().enumerate() { 1043 | if name == account_name { 1044 | target_index = Some(index); 1045 | break; 1046 | } 1047 | } 1048 | if let Some(index) = target_index { 1049 | account_list.content.set_selection(index); 1050 | } 1051 | }; 1052 | self.update_detail(); 1053 | } 1054 | 1055 | /// Retrieve the next available UiMessage to process. 1056 | pub fn next_ui_message(&self) -> Option { 1057 | self.ui_rx.try_iter().next() 1058 | } 1059 | 1060 | /// Step the UI by calling into Cursive's step function, then processing any UI messages. 1061 | pub fn step(&mut self) -> bool { 1062 | if !self.cursive.is_running() { 1063 | return false; 1064 | } 1065 | 1066 | // Step the UI 1067 | self.cursive.step(); 1068 | 1069 | // Process any UI messages 1070 | while let Some(message) = self.next_ui_message() { 1071 | match message { 1072 | UiMessage::UpdateStatus => self.update_status(), 1073 | UiMessage::ShowAccountEdit(a) => self.handle_show_account_edit(a), 1074 | UiMessage::ShowDatabaseEdit => self.handle_show_database_edit(), 1075 | UiMessage::RequireSync => self.handle_require_sync(), 1076 | UiMessage::ChangePassword => self.handle_change_password(), 1077 | UiMessage::Refresh => self.handle_refresh(), 1078 | } 1079 | } 1080 | true 1081 | } 1082 | 1083 | /// Handle UiMessage::ShowAccountEdit messages. 1084 | fn handle_show_account_edit(&mut self, account: Option) { 1085 | match account { 1086 | Some(a) => AccountEditView::show( 1087 | &mut self.cursive, 1088 | self.database.clone(), 1089 | self.controller_tx.clone(), 1090 | Some(&a), 1091 | ), 1092 | None => AccountEditView::show( 1093 | &mut self.cursive, 1094 | self.database.clone(), 1095 | self.controller_tx.clone(), 1096 | None, 1097 | ), 1098 | }; 1099 | } 1100 | 1101 | /// Handle UiMessage::ShowDatabaseEdit messages. 1102 | fn handle_show_database_edit(&mut self) { 1103 | DatabaseEditView::show( 1104 | &mut self.cursive, 1105 | self.database.clone(), 1106 | self.controller_tx.clone(), 1107 | ); 1108 | } 1109 | 1110 | /// Handle UiMessage::RequireSync messages. 1111 | fn handle_require_sync(&mut self) { 1112 | let text = "The database should be synchronized before editing \ 1113 | accounts. Synchronize now?"; 1114 | let controller_tx_clone = self.controller_tx.clone(); 1115 | self.cursive.add_layer( 1116 | Dialog::around(TextView::new(text)) 1117 | .button("No", |s| { 1118 | s.pop_layer(); 1119 | }) 1120 | .button("Yes", move |s| { 1121 | s.pop_layer(); 1122 | controller_tx_clone.send(controller::Message::Sync).unwrap(); 1123 | // It would be nice to open the account edit 1124 | // dialog here, but the account data will be 1125 | // potentially stale until the controller 1126 | // processes the Sync. 1127 | }) 1128 | .title("Database not synchronized"), 1129 | ); 1130 | } 1131 | 1132 | /// Handle UiMessage::ChangePassword messages. 1133 | fn handle_change_password(&mut self) { 1134 | let password = self.password_dialog( 1135 | "Please provide a new master password for this new database:", 1136 | false, 1137 | ); 1138 | let password = match password { 1139 | Some(p) => p, 1140 | None => return, 1141 | }; 1142 | 1143 | self.controller_tx 1144 | .send(controller::Message::ChangePassword(password)) 1145 | .unwrap(); 1146 | } 1147 | 1148 | /// Handle UiMessage::Refresh messages. 1149 | fn handle_refresh(&mut self) { 1150 | self.cursive.clear(); 1151 | } 1152 | 1153 | /// Quit. 1154 | pub fn quit(&mut self) { 1155 | self.cursive.quit(); 1156 | } 1157 | 1158 | /// Present a modal dialog to the user and step the UI until the dialog is dismissed. This is 1159 | /// a synchronous operation, and will not return until the dialog is finished. 1160 | fn modal_dialog(&mut self, dialog: Dialog) { 1161 | self.cursive.add_layer(dialog.with_id(VIEW_ID_MODAL)); 1162 | while self.cursive.is_running() && self.cursive.find_id::(VIEW_ID_MODAL).is_some() { 1163 | self.cursive.step(); 1164 | } 1165 | } 1166 | 1167 | /// Present a modal confirmation dialog to the user and step the UI until the dialog is 1168 | /// dismissed. Returns true if the button with "true_text" was selected; otherwise false. 1169 | /// 1170 | /// This is a synchronous operation, and will not return until the dialog is finished. 1171 | pub fn yesno_dialog( 1172 | &mut self, 1173 | title: &str, 1174 | text: &str, 1175 | false_text: &str, 1176 | true_text: &str, 1177 | ) -> bool { 1178 | let result = Rc::new(Cell::new(false)); 1179 | { 1180 | let result = result.clone(); 1181 | self.modal_dialog( 1182 | Dialog::around(TextView::new(text)) 1183 | .button(false_text, |s| { 1184 | s.pop_layer(); 1185 | }) 1186 | .button(true_text, move |s| { 1187 | result.set(true); 1188 | s.pop_layer(); 1189 | }) 1190 | .title(title), 1191 | ); 1192 | } 1193 | result.get() 1194 | } 1195 | 1196 | /// Present a modal dialog to the user displaying a short notice. 1197 | /// 1198 | /// Step the UI until the dialog is dismissed. This is a synchronous operation, and will not 1199 | /// return until the dialog is finished. 1200 | pub fn notice_dialog(&mut self, title: &str, text: &str) { 1201 | self.modal_dialog( 1202 | Dialog::around(TextView::new(text)) 1203 | .button("OK", move |s| { 1204 | s.pop_layer(); 1205 | }) 1206 | .title(title), 1207 | ); 1208 | } 1209 | 1210 | /// Present a modal password dialog to the user and step the UI until the dialog is dismissed. 1211 | /// Returns a password if one was provided, otherwise returns None if the password field was 1212 | /// left empty or cancel was selected. This is a synchronous operation, and will not return 1213 | /// until the dialog is finished. 1214 | pub fn password_dialog(&mut self, text: &str, secret: bool) -> Option { 1215 | let result = Rc::new(RefCell::new(None)); 1216 | { 1217 | let result_clone1 = result.clone(); 1218 | let result_clone2 = result.clone(); 1219 | let mut editview = EditView::new().on_submit(move |s, text| { 1220 | if !text.is_empty() { 1221 | *result_clone1.borrow_mut() = Some(String::from(text)); 1222 | } 1223 | s.pop_layer(); 1224 | s.focus_id(VIEW_ID_SELECT).ok(); 1225 | }); 1226 | editview.set_secret(secret); 1227 | let layout = LinearLayout::vertical() 1228 | .child(TextView::new(text)) 1229 | .child(editview.with_id(VIEW_ID_INPUT)); 1230 | self.modal_dialog( 1231 | Dialog::around(layout) 1232 | .button("Ok", move |s| { 1233 | let text = s.find_id::(VIEW_ID_INPUT).unwrap().get_content(); 1234 | if !text.is_empty() { 1235 | *result_clone2.borrow_mut() = Some((*text).clone()); 1236 | } 1237 | s.pop_layer(); 1238 | s.focus_id(VIEW_ID_SELECT).ok(); 1239 | }) 1240 | .dismiss_button("Cancel") 1241 | .title("Enter password"), 1242 | ); 1243 | } 1244 | let result = match *result.borrow() { 1245 | Some(ref s) => Some(s.clone()), 1246 | None => None, 1247 | }; 1248 | result 1249 | } 1250 | 1251 | /// The internals of the AccountSelectView can't push details of the selected account directly 1252 | /// to the detail TextView, since it doesn't have a reference to the toplevel Cursive. 1253 | /// Therefore, we need this independent function. 1254 | fn update_detail(&mut self) { 1255 | let details = match self.cursive.find_id::(VIEW_ID_SELECT) { 1256 | Some(account_list) => { 1257 | match account_list 1258 | .selection() 1259 | .map(|a| render_account_text(&a, false)) 1260 | { 1261 | Some(details) => details, 1262 | None => String::from(""), 1263 | } 1264 | } 1265 | None => String::from(""), 1266 | }; 1267 | let mut account_detail = match self.cursive.find_id::(VIEW_ID_DETAIL) { 1268 | Some(account_detail) => account_detail, 1269 | None => return, 1270 | }; 1271 | account_detail.set_content(details); 1272 | } 1273 | 1274 | /// Update the UI status information: count, revision, etc. 1275 | pub fn update_status(&mut self) { 1276 | let (counts, revision) = match self.cursive.find_id::(VIEW_ID_SELECT) { 1277 | Some(account_list) => ( 1278 | (account_list.display_count(), account_list.count()), 1279 | self.database.borrow().sync_revision, 1280 | ), 1281 | None => ((0, 0), 0), 1282 | }; 1283 | if let Some(mut count_text) = self.cursive.find_id::(VIEW_ID_COUNT) { 1284 | count_text.set_content(format!("{}/{} accounts", counts.0, counts.1)); 1285 | }; 1286 | if let Some(mut revision_text) = self.cursive.find_id::(VIEW_ID_REVISION) { 1287 | if revision != 0 { 1288 | revision_text.set_content(format!("Revision {}", revision)); 1289 | } else { 1290 | revision_text.set_content("") 1291 | }; 1292 | }; 1293 | if let Some(mut modified_text) = self.cursive.find_id::(VIEW_ID_MODIFIED) { 1294 | if !self.database.borrow().is_synced() { 1295 | modified_text.set_content(" UNSYNCHRONIZED"); 1296 | } else { 1297 | modified_text.set_content(""); 1298 | } 1299 | }; 1300 | } 1301 | 1302 | /// Update the status line. 1303 | pub fn set_statusline(&mut self, text: &str) { 1304 | match self.cursive.find_id::(VIEW_ID_STATUSLINE) { 1305 | Some(mut statusline_text) => { 1306 | statusline_text.set_content(text); 1307 | } 1308 | None => {} 1309 | } 1310 | } 1311 | } 1312 | 1313 | /// Return a reference to the currently selected account. 1314 | fn selected_account(cursive: &mut Cursive) -> Option> { 1315 | let select = cursive 1316 | .find_id::(VIEW_ID_SELECT) 1317 | .unwrap(); 1318 | select.selection() 1319 | } 1320 | 1321 | /// Render account details into a single text string. 1322 | fn render_account_text(account: &Account, reveal_password: bool) -> String { 1323 | fn indent_multiline(value: &str) -> String { 1324 | // TODO: Ideally, this would be smart about preserving indentation for long strings that 1325 | // wrap around. 1326 | String::from(value.trim().replace("\n", "\n ").as_str()) 1327 | } 1328 | fn render_line(text: &mut String, field: &str, value: &String) { 1329 | let mut label = String::from(field); 1330 | label.push(':'); 1331 | text.push_str(&(format!("{:10}{}\n", label, indent_multiline(value)))[..]); 1332 | }; 1333 | let password; 1334 | if reveal_password { 1335 | password = account.password.clone(); 1336 | } else { 1337 | password = String::from("************"); 1338 | }; 1339 | let mut text = String::new(); 1340 | render_line(&mut text, FIELD_NAME, &account.name); 1341 | render_line(&mut text, FIELD_USER, &account.user); 1342 | render_line(&mut text, FIELD_PASSWORD, &password); 1343 | render_line(&mut text, FIELD_URL, &account.url); 1344 | render_line(&mut text, FIELD_NOTES, &account.notes); 1345 | text 1346 | } 1347 | 1348 | /// Confirm that the database has been recently synced. If it hasn't, then return true and arrange 1349 | /// for a "sync?" dialog box to be presented. 1350 | fn sync_guard(database: &T, channel: &mpsc::Sender) -> bool 1351 | where 1352 | T: Deref, 1353 | { 1354 | if database.has_remote() && !database.is_synced() { 1355 | channel.send(UiMessage::RequireSync).unwrap(); 1356 | true 1357 | } else { 1358 | false 1359 | } 1360 | } 1361 | --------------------------------------------------------------------------------