diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index df0a1ff9..3a95d8ed 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,4 +1,5 @@ name: tests + on: push: branches: [main] diff --git a/.rustfmt.toml b/.rustfmt.toml index 7b6182f2..a311b9da 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -1,2 +1,2 @@ edition = "2021" -max_width = 78 +newline_style = "Unix" diff --git a/CHANGELOG.md b/CHANGELOG.md index 729f5b58..ffb2fe22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [Unreleased] + +## Added + +* Added `confirm_ssh` configuration option. When set to `true`, the agent will + ask for pinentry confirmation before every SSH signature request. +* Added Nix flake support (`flake.nix`, `flake.lock`, `shell.nix`). + +## Changed + +* The `rbw` client and agent are now fully async internally. +* The local database is now cached in the agent's state, reducing disk reads. +* Notifications were refactored to use `tokio::sync::broadcast` instead of a + manual vector of senders. +* The `Field` enum was replaced by `rbw::db::FieldType`, which now supports a + `Custom(String)` variant. Unknown field names given to `rbw get --field` are + now treated as custom field lookups instead of returning an error. +* Removed unused dependencies: `arrayvec`, `is-terminal`, `tokio-stream`. +* `.rustfmt.toml`: removed `max_width = 78`, added `newline_style = "Unix"`. +* Removed the extensive clippy lint configuration from `Cargo.toml`. +* `dirs.rs` helpers now return `Result` instead of potentially panicking. +* `timeout.rs` was removed in favor of a simple Instant based deadline + mechanism. + +## Fixed + +* Fixed broken protocol version calculation where minor and patch components + were incorrectly scaled by 1_000_000. +* Various error types and messages have changed; backwards compatibility of + error text is not guaranteed. + ## [1.15.0] - 2025-12-31 ## Added diff --git a/Cargo.lock b/Cargo.lock index c8b0964d..c4fa2226 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,12 +108,6 @@ dependencies = [ "password-hash", ] -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - [[package]] name = "async-trait" version = "0.1.89" @@ -266,9 +260,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cbc" @@ -869,12 +863,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hkdf" version = "0.12.4" @@ -1154,17 +1142,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "is-wsl" version = "0.4.0" @@ -1350,7 +1327,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1744,7 +1721,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1787,9 +1764,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -1798,9 +1775,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -1855,13 +1832,12 @@ dependencies = [ [[package]] name = "rbw" -version = "1.15.0" +version = "1.16.0-rc1" dependencies = [ "aes", "anyhow", "arboard", "argon2", - "arrayvec", "axum", "base32", "base64", @@ -1880,15 +1856,14 @@ dependencies = [ "hkdf", "hmac", "humantime", - "is-terminal", "libc", "log", "open", "pbkdf2", "percent-encoding", "pkcs8", - "rand 0.8.5", - "rand 0.9.2", + "rand 0.8.6", + "rand 0.9.4", "regex", "region", "reqwest", @@ -1908,7 +1883,6 @@ dependencies = [ "textwrap", "thiserror 2.0.17", "tokio", - "tokio-stream", "tokio-tungstenite", "totp-rs", "url", @@ -2147,9 +2121,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -2663,17 +2637,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -2714,6 +2677,8 @@ dependencies = [ "hmac", "sha1", "sha2", + "url", + "urlencoding", ] [[package]] @@ -2811,7 +2776,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.2", + "rand 0.9.4", "rustls", "rustls-pki-types", "sha1", diff --git a/Cargo.toml b/Cargo.toml index d348fe04..423d68e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rbw" -version = "1.15.0" +version = "1.16.0-rc1" authors = ["Jesse Luehrs "] edition = "2021" rust-version = "1.82.0" @@ -17,7 +17,6 @@ include = ["src/**/*", "bin/**/*", "LICENSE", "README.md", "CHANGELOG.md"] aes = "0.8.4" anyhow = "1.0.100" argon2 = "0.5.3" -arrayvec = "0.7.6" axum = "0.8.8" base32 = "0.5.1" base64 = "0.22.1" @@ -36,7 +35,6 @@ futures-util = "0.3.31" hkdf = "0.12.4" hmac = { version = "0.12.1", features = ["std"] } humantime = "2.3.0" -is-terminal = "0.4.17" libc = "0.2.178" log = "0.4.29" open = "5.3.3" @@ -72,13 +70,12 @@ tempfile = "3.24.0" terminal_size = "0.4.3" textwrap = "0.16.2" thiserror = "2.0.17" -tokio-stream = { version = "0.1.17", features = ["net"] } tokio-tungstenite = { version = "0.28", features = [ "rustls-tls-native-roots", "url", ] } tokio = { version = "1.48.0", features = ["full"] } -totp-rs = { version = "5.7.0", features = ["steam"] } +totp-rs = { version = "5.7.0", features = ["steam", "otpauth"] } url = "2.5.7" urlencoding = "2.1.3" uuid = { version = "1.19.0", features = ["v4"] } @@ -92,28 +89,6 @@ arboard = { version = "3.6.1", default-features = false, features = [ default = ["clipboard"] clipboard = ["arboard"] -[lints.clippy] -cargo = { level = "warn", priority = -1 } -pedantic = { level = "warn", priority = -1 } -nursery = { level = "warn", priority = -1 } -as_conversions = "warn" -get_unwrap = "warn" -cognitive_complexity = "allow" -missing_const_for_fn = "allow" -similar_names = "allow" -struct_excessive_bools = "allow" -fn_params_excessive_bools = "allow" -too_many_arguments = "allow" -too_many_lines = "allow" -type_complexity = "allow" -multiple_crate_versions = "allow" -large_enum_variant = "allow" -must_use_candidate = "allow" -missing_errors_doc = "allow" -missing_panics_doc = "allow" -significant_drop_tightening = "allow" -struct_field_names = "allow" - [package.metadata.deb] depends = "pinentry" license-file = ["LICENSE"] diff --git a/README.md b/README.md index eb9074b8..638e2d94 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,49 @@ similar to the way that `ssh-agent` or `gpg-agent` work. This allows the client to be used in a much simpler way, with the background agent taking care of maintaining the necessary state. +## Fork + +Since the original developer of this project has not been active in the last +months, I took the project and heavily refactored it. + +There were all the signs of a project that grew over time without chance to +receive some maintenance. + +Around 20%-30% of program's logic was duplicated. There was no clear separation +of concerns, etc. Now it's not perfect of course but I will work towards making +it easily auditable and maintainable. + +I don't blame the author as this is all free time and unpaid labor, and on top +of that, he actually provided the community with a great tool. + +NOTE: This fork has not 100% error messages backwards compatibility. Some of +them have changed. + +I couldn't do anything about it, as it was way easier to do things this way. +However it should not impact any actual tool built on rbw, but beware, the bug +is behind the corner! + +Oh and there also is my confirm ssh feature baked in the code, which is totally +optional. + ## Maintenance -I consider `rbw` to be essentially feature-complete for me at this point. While -I still use it on a daily basis, and will continue to fix regressions as they -occur, I am unlikely to spend time implementing new features on my own. If you -would like to see new functionality in `rbw`, I am more than happy to review -and merge pull requests implementing those features. +I DO NOT consider rbw to be essentially feature-complete, BUT in this first +phase I will accept PRs that fix bugs or enhance code readability. + +The first big elephant in the room to address is the fact that the client +should read no DB and therefore all search/get/etc. operations must be +performed by the daemon, while the protocol must provide such "opcodes" to do +it. + +## Before continuing + +The rest of this README is untouched from the original's, so if you install rbw +from repositories, you will not install this version but the older one. Same +for the listed tools. + +I am working to get this new version in the repositories, but it's not +immediate. ## Installation @@ -96,6 +132,8 @@ configuration options: * `pinentry`: The [pinentry](https://www.gnupg.org/related_software/pinentry/index.html) executable to use. Defaults to `pinentry`. +* `confirm_ssh`: If set to `true` will ask for confirmation for SSH signature +requests. If unset defaults to not asking. ### Profiles diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..c6670e17 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1777954456, + "narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..de949c9a --- /dev/null +++ b/flake.nix @@ -0,0 +1,18 @@ +{ + description = "rbw: unofficial bitwarden cli"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + { + + packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello; + + packages.x86_64-linux.default = self.packages.x86_64-linux.hello; + devShells.x86_64-linux.default = import ./shell.nix { pkgs = nixpkgs.legacyPackages.x86_64-linux; }; + + }; +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..a0adb894 --- /dev/null +++ b/shell.nix @@ -0,0 +1,16 @@ +{ + pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz") { }, +}: + +pkgs.mkShell { + buildInputs = with pkgs; [ + gdb + rustc + cargo + cargo-deny + rust-analyzer + rustfmt + clippy + pinentry-all + ]; +} diff --git a/src/actions.rs b/src/actions.rs index 79d304d4..5943675a 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -1,9 +1,9 @@ -use crate::prelude::*; +use crate::{ + db::{Encrypted, Entry}, + prelude::*, +}; -pub async fn register( - email: &str, - apikey: crate::locked::ApiKey, -) -> Result<()> { +pub async fn register(email: &str, apikey: crate::locked::ApiKey) -> Result<()> { let (client, config) = api_client_async().await?; client @@ -13,32 +13,31 @@ pub async fn register( Ok(()) } +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct CryptoParameters { + pub kdf: crate::api::KdfType, + pub iterations: u32, + pub memory: Option, + pub parallelism: Option, +} + +pub struct SessionParameters { + pub access_token: String, + pub refresh_token: String, + pub crypto_params: CryptoParameters, + pub protected_key: String, +} + pub async fn login( email: &str, - password: crate::locked::Password, + password: &crate::locked::Password, two_factor_token: Option<&str>, two_factor_provider: Option, -) -> Result<( - String, - String, - crate::api::KdfType, - u32, - Option, - Option, - String, -)> { +) -> Result { let (client, config) = api_client_async().await?; - let (kdf, iterations, memory, parallelism) = - client.prelogin(email).await?; + let crypto_params = client.prelogin(email).await?; - let identity = crate::identity::Identity::new( - email, - &password, - kdf, - iterations, - memory, - parallelism, - )?; + let identity = crate::identity::Identity::new(email, password, &crypto_params)?; let (access_token, refresh_token, protected_key) = client .login( email, @@ -50,21 +49,15 @@ pub async fn login( ) .await?; - Ok(( + Ok(SessionParameters { access_token, refresh_token, - kdf, - iterations, - memory, - parallelism, + crypto_params, protected_key, - )) + }) } -pub async fn send_two_factor_email( - email: &str, - sso_email_2fa_session_token: &str, -) -> Result<()> { +pub async fn send_two_factor_email(email: &str, sso_email_2fa_session_token: &str) -> Result<()> { let (client, config) = api_client_async().await?; client .send_email_login( @@ -78,10 +71,7 @@ pub async fn send_two_factor_email( pub fn unlock( email: &str, password: &crate::locked::Password, - kdf: crate::api::KdfType, - iterations: u32, - memory: Option, - parallelism: Option, + crypto_params: &CryptoParameters, protected_key: &str, protected_private_key: &str, protected_org_keys: &std::collections::HashMap, @@ -89,17 +79,9 @@ pub fn unlock( crate::locked::Keys, std::collections::HashMap, )> { - let identity = crate::identity::Identity::new( - email, - password, - kdf, - iterations, - memory, - parallelism, - )?; + let identity = crate::identity::Identity::new(email, password, crypto_params)?; - let protected_key = - crate::cipherstring::CipherString::new(protected_key)?; + let protected_key = crate::cipherstring::CipherString::new(protected_key)?; let key = match protected_key.decrypt_locked_symmetric(&identity.keys) { Ok(master_keys) => crate::locked::Keys::new(master_keys), Err(Error::InvalidMac) => { @@ -110,49 +92,42 @@ pub fn unlock( Err(e) => return Err(e), }; - let protected_private_key = - crate::cipherstring::CipherString::new(protected_private_key)?; - let private_key = - match protected_private_key.decrypt_locked_symmetric(&key) { - Ok(private_key) => crate::locked::PrivateKey::new(private_key), - Err(e) => return Err(e), - }; + let protected_private_key = crate::cipherstring::CipherString::new(protected_private_key)?; + let private_key = match protected_private_key.decrypt_locked_symmetric(&key) { + Ok(private_key) => crate::locked::PrivateKey::new(private_key), + Err(e) => return Err(e), + }; let mut org_keys = std::collections::HashMap::new(); for (org_id, protected_org_key) in protected_org_keys { - let protected_org_key = - crate::cipherstring::CipherString::new(protected_org_key)?; - let org_key = - match protected_org_key.decrypt_locked_asymmetric(&private_key) { - Ok(org_key) => crate::locked::Keys::new(org_key), - Err(e) => return Err(e), - }; + let protected_org_key = crate::cipherstring::CipherString::new(protected_org_key)?; + let org_key = match protected_org_key.decrypt_locked_asymmetric(&private_key) { + Ok(org_key) => crate::locked::Keys::new(org_key), + Err(e) => return Err(e), + }; org_keys.insert(org_id.clone(), org_key); } Ok((key, org_keys)) } +// TODO: This return type could be a struct, like SyncCredentials? pub async fn sync( access_token: &str, refresh_token: &str, ) -> Result<( + Option, Option, ( String, String, std::collections::HashMap, - Vec, + Vec>, ), )> { - with_exchange_refresh_token_async( - access_token, - refresh_token, - |access_token| { - let access_token = access_token.to_string(); - Box::pin(async move { sync_once(&access_token).await }) - }, - ) + with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { + sync_once(&token).await + }) .await } @@ -162,205 +137,132 @@ async fn sync_once( String, String, std::collections::HashMap, - Vec, + Vec>, )> { let (client, _) = api_client_async().await?; client.sync(access_token).await } -pub fn add( +pub async fn add( access_token: &str, refresh_token: &str, name: &str, data: &crate::db::EntryData, notes: Option<&str>, folder_id: Option<&str>, -) -> Result<(Option, ())> { - with_exchange_refresh_token(access_token, refresh_token, |access_token| { - add_once(access_token, name, data, notes, folder_id) +) -> Result<(Option, Option, ())> { + with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { + add_once(&token, name, data, notes, folder_id).await }) + .await } -fn add_once( +async fn add_once( access_token: &str, name: &str, data: &crate::db::EntryData, notes: Option<&str>, folder_id: Option<&str>, ) -> Result<()> { - let (client, _) = api_client()?; - client.add(access_token, name, data, notes, folder_id)?; + let (client, _) = api_client_async().await?; + client + .add(access_token, name, data, notes, folder_id) + .await?; Ok(()) } -pub fn edit( - access_token: &str, - refresh_token: &str, - id: &str, - org_id: Option<&str>, - name: &str, - data: &crate::db::EntryData, - fields: &[crate::db::Field], - notes: Option<&str>, - folder_uuid: Option<&str>, - history: &[crate::db::HistoryEntry], -) -> Result<(Option, ())> { - with_exchange_refresh_token(access_token, refresh_token, |access_token| { - edit_once( - access_token, - id, - org_id, - name, - data, - fields, - notes, - folder_uuid, - history, - ) - }) +async fn edit_once(access_token: &str, entry: &crate::db::Entry) -> Result<()> { + let (client, _) = api_client_async().await?; + client.edit(access_token, entry).await } -fn edit_once( +pub async fn edit( access_token: &str, - id: &str, - org_id: Option<&str>, - name: &str, - data: &crate::db::EntryData, - fields: &[crate::db::Field], - notes: Option<&str>, - folder_uuid: Option<&str>, - history: &[crate::db::HistoryEntry], -) -> Result<()> { - let (client, _) = api_client()?; - client.edit( - access_token, - id, - org_id, - name, - data, - fields, - notes, - folder_uuid, - history, - )?; - Ok(()) + refresh_token: &str, + entry: &Entry, +) -> Result<(Option, Option, ())> { + with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { + edit_once(&token, entry).await + }) + .await } -pub fn remove( +pub async fn remove( access_token: &str, refresh_token: &str, id: &str, -) -> Result<(Option, ())> { - with_exchange_refresh_token(access_token, refresh_token, |access_token| { - remove_once(access_token, id) +) -> Result<(Option, Option, ())> { + with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { + remove_once(&token, id).await }) + .await } -fn remove_once(access_token: &str, id: &str) -> Result<()> { - let (client, _) = api_client()?; - client.remove(access_token, id)?; +async fn remove_once(access_token: &str, id: &str) -> Result<()> { + let (client, _) = api_client_async().await?; + client.remove(access_token, id).await?; Ok(()) } -pub fn list_folders( +pub async fn list_folders( access_token: &str, refresh_token: &str, -) -> Result<(Option, Vec<(String, String)>)> { - with_exchange_refresh_token(access_token, refresh_token, |access_token| { - list_folders_once(access_token) +) -> Result<(Option, Option, Vec<(String, String)>)> { + with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { + list_folders_once(&token).await }) + .await } -fn list_folders_once(access_token: &str) -> Result> { - let (client, _) = api_client()?; - client.folders(access_token) +async fn list_folders_once(access_token: &str) -> Result> { + let (client, _) = api_client_async().await?; + client.folders(access_token).await } -pub fn create_folder( +pub async fn create_folder( access_token: &str, refresh_token: &str, name: &str, -) -> Result<(Option, String)> { - with_exchange_refresh_token(access_token, refresh_token, |access_token| { - create_folder_once(access_token, name) +) -> Result<(Option, Option, String)> { + with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { + create_folder_once(&token, name).await }) + .await } -fn create_folder_once(access_token: &str, name: &str) -> Result { - let (client, _) = api_client()?; - client.create_folder(access_token, name) -} - -fn with_exchange_refresh_token( - access_token: &str, - refresh_token: &str, - f: F, -) -> Result<(Option, T)> -where - F: Fn(&str) -> Result, -{ - match f(access_token) { - Ok(t) => Ok((None, t)), - Err(Error::RequestUnauthorized) => { - let access_token = exchange_refresh_token(refresh_token)?; - let t = f(&access_token)?; - Ok((Some(access_token), t)) - } - Err(e) => Err(e), - } +async fn create_folder_once(access_token: &str, name: &str) -> Result { + let (client, _) = api_client_async().await?; + client.create_folder(access_token, name).await } -async fn with_exchange_refresh_token_async( +async fn with_exchange_refresh_token_async( access_token: &str, refresh_token: &str, - f: F, -) -> Result<(Option, T)> + mut f: F, +) -> Result<(Option, Option, T)> where - F: Fn( - &str, - ) -> std::pin::Pin< - Box> + Send>, - > + Send - + Sync, - T: Send, + F: FnMut(String) -> Fut, + Fut: std::future::Future>, { - match f(access_token).await { - Ok(t) => Ok((None, t)), + match f(access_token.to_string()).await { + Ok(t) => Ok((None, None, t)), Err(Error::RequestUnauthorized) => { - let access_token = - exchange_refresh_token_async(refresh_token).await?; - let t = f(&access_token).await?; - Ok((Some(access_token), t)) + let (new_access, new_refresh) = exchange_refresh_token_async(refresh_token).await?; + let t = f(new_access.clone()).await?; + Ok((Some(new_access), new_refresh, t)) } Err(e) => Err(e), } } -fn exchange_refresh_token(refresh_token: &str) -> Result { - let (client, _) = api_client()?; - client.exchange_refresh_token(refresh_token) -} - -async fn exchange_refresh_token_async(refresh_token: &str) -> Result { - let (client, _) = api_client()?; +async fn exchange_refresh_token_async(refresh_token: &str) -> Result<(String, Option)> { + let (client, _) = api_client_async().await?; client.exchange_refresh_token_async(refresh_token).await } -fn api_client() -> Result<(crate::api::Client, crate::config::Config)> { +async fn api_client_async() -> Result<(crate::api::client::Client, crate::config::Config)> { let config = crate::config::Config::load()?; - let client = crate::api::Client::new( - &config.base_url(), - &config.identity_url(), - &config.ui_url(), - config.client_cert_path(), - ); - Ok((client, config)) -} - -async fn api_client_async( -) -> Result<(crate::api::Client, crate::config::Config)> { - let config = crate::config::Config::load_async().await?; - let client = crate::api::Client::new( + let client = crate::api::client::Client::new( &config.base_url(), &config.identity_url(), &config.ui_url(), diff --git a/src/api.rs b/src/api.rs deleted file mode 100644 index a817fb26..00000000 --- a/src/api.rs +++ /dev/null @@ -1,1768 +0,0 @@ -// serde_repr generates some as conversions that we can't seem to silence from -// here, unfortunately -#![allow(clippy::as_conversions)] - -use crate::prelude::*; - -use rand::distr::SampleString as _; -use sha2::Digest as _; -use tokio::io::AsyncReadExt as _; - -use crate::json::{ - DeserializeJsonWithPath as _, DeserializeJsonWithPathAsync as _, -}; - -#[derive( - serde_repr::Serialize_repr, - serde_repr::Deserialize_repr, - Debug, - Copy, - Clone, - PartialEq, - Eq, -)] -#[repr(u8)] -pub enum UriMatchType { - Domain = 0, - Host = 1, - StartsWith = 2, - Exact = 3, - RegularExpression = 4, - Never = 5, -} - -impl std::fmt::Display for UriMatchType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - #[allow(clippy::enum_glob_use)] - use UriMatchType::*; - let s = match self { - Domain => "domain", - Host => "host", - StartsWith => "starts_with", - Exact => "exact", - RegularExpression => "regular_expression", - Never => "never", - }; - write!(f, "{s}") - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum TwoFactorProviderType { - Authenticator = 0, - Email = 1, - Duo = 2, - Yubikey = 3, - U2f = 4, - Remember = 5, - OrganizationDuo = 6, - WebAuthn = 7, -} - -impl TwoFactorProviderType { - pub fn message(&self) -> &str { - match *self { - Self::Authenticator => "Enter the 6 digit verification code from your authenticator app.", - Self::Yubikey => "Insert your Yubikey and push the button.", - Self::Email => "Enter the PIN you received via email.", - _ => "Enter the code." - } - } - - pub fn header(&self) -> &str { - match *self { - Self::Authenticator => "Authenticator App", - Self::Yubikey => "Yubikey", - Self::Email => "Email Code", - _ => "Two Factor Authentication", - } - } - - pub fn grab(&self) -> bool { - !matches!(self, Self::Email) - } -} - -impl<'de> serde::Deserialize<'de> for TwoFactorProviderType { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - struct TwoFactorProviderTypeVisitor; - impl serde::de::Visitor<'_> for TwoFactorProviderTypeVisitor { - type Value = TwoFactorProviderType; - - fn expecting( - &self, - formatter: &mut std::fmt::Formatter, - ) -> std::fmt::Result { - formatter.write_str("two factor provider id") - } - - fn visit_str( - self, - value: &str, - ) -> std::result::Result - where - E: serde::de::Error, - { - value.parse().map_err(serde::de::Error::custom) - } - - fn visit_u64( - self, - value: u64, - ) -> std::result::Result - where - E: serde::de::Error, - { - std::convert::TryFrom::try_from(value) - .map_err(serde::de::Error::custom) - } - } - - deserializer.deserialize_any(TwoFactorProviderTypeVisitor) - } -} - -impl std::convert::TryFrom for TwoFactorProviderType { - type Error = Error; - - fn try_from(ty: u64) -> Result { - match ty { - 0 => Ok(Self::Authenticator), - 1 => Ok(Self::Email), - 2 => Ok(Self::Duo), - 3 => Ok(Self::Yubikey), - 4 => Ok(Self::U2f), - 5 => Ok(Self::Remember), - 6 => Ok(Self::OrganizationDuo), - 7 => Ok(Self::WebAuthn), - _ => Err(Error::InvalidTwoFactorProvider { - ty: format!("{ty}"), - }), - } - } -} - -impl std::str::FromStr for TwoFactorProviderType { - type Err = Error; - - fn from_str(ty: &str) -> Result { - match ty { - "0" => Ok(Self::Authenticator), - "1" => Ok(Self::Email), - "2" => Ok(Self::Duo), - "3" => Ok(Self::Yubikey), - "4" => Ok(Self::U2f), - "5" => Ok(Self::Remember), - "6" => Ok(Self::OrganizationDuo), - "7" => Ok(Self::WebAuthn), - _ => Err(Error::InvalidTwoFactorProvider { ty: ty.to_string() }), - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum KdfType { - Pbkdf2 = 0, - Argon2id = 1, -} - -impl<'de> serde::Deserialize<'de> for KdfType { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - struct KdfTypeVisitor; - impl serde::de::Visitor<'_> for KdfTypeVisitor { - type Value = KdfType; - - fn expecting( - &self, - formatter: &mut std::fmt::Formatter, - ) -> std::fmt::Result { - formatter.write_str("kdf id") - } - - fn visit_str( - self, - value: &str, - ) -> std::result::Result - where - E: serde::de::Error, - { - value.parse().map_err(serde::de::Error::custom) - } - - fn visit_u64( - self, - value: u64, - ) -> std::result::Result - where - E: serde::de::Error, - { - std::convert::TryFrom::try_from(value) - .map_err(serde::de::Error::custom) - } - } - - deserializer.deserialize_any(KdfTypeVisitor) - } -} - -impl std::convert::TryFrom for KdfType { - type Error = Error; - - fn try_from(ty: u64) -> Result { - match ty { - 0 => Ok(Self::Pbkdf2), - 1 => Ok(Self::Argon2id), - _ => Err(Error::InvalidKdfType { - ty: format!("{ty}"), - }), - } - } -} - -impl std::str::FromStr for KdfType { - type Err = Error; - - fn from_str(ty: &str) -> Result { - match ty { - "0" => Ok(Self::Pbkdf2), - "1" => Ok(Self::Argon2id), - _ => Err(Error::InvalidKdfType { ty: ty.to_string() }), - } - } -} - -impl serde::Serialize for KdfType { - fn serialize( - &self, - serializer: S, - ) -> std::result::Result - where - S: serde::Serializer, - { - let s = match self { - Self::Pbkdf2 => "0", - Self::Argon2id => "1", - }; - serializer.serialize_str(s) - } -} - -#[derive( - serde_repr::Serialize_repr, - serde_repr::Deserialize_repr, - Debug, - Copy, - Clone, - PartialEq, - Eq, -)] -#[repr(u8)] -pub enum CipherRepromptType { - None = 0, - Password = 1, -} - -#[derive(serde::Serialize, Debug)] -struct PreloginReq { - email: String, -} - -#[derive(serde::Deserialize, Debug)] -struct PreloginRes { - #[serde(rename = "Kdf", alias = "kdf")] - kdf: KdfType, - #[serde(rename = "KdfIterations", alias = "kdfIterations")] - kdf_iterations: u32, - #[serde(rename = "KdfMemory", alias = "kdfMemory")] - kdf_memory: Option, - #[serde(rename = "KdfParallelism", alias = "kdfParallelism")] - kdf_parallelism: Option, -} - -#[derive(serde::Serialize, Debug)] -struct ConnectTokenReq { - grant_type: String, - scope: String, - client_id: String, - #[serde(rename = "deviceType")] - device_type: u32, - #[serde(rename = "deviceIdentifier")] - device_identifier: String, - #[serde(rename = "deviceName")] - device_name: String, - #[serde(rename = "devicePushToken")] - device_push_token: String, - #[serde(rename = "twoFactorToken")] - two_factor_token: Option, - #[serde(rename = "twoFactorProvider")] - two_factor_provider: Option, - #[serde(flatten)] - auth: ConnectTokenAuth, -} - -#[derive(serde::Serialize, Debug)] -#[serde(untagged)] -enum ConnectTokenAuth { - Password(ConnectTokenPassword), - AuthCode(ConnectTokenAuthCode), - ClientCredentials(ConnectTokenClientCredentials), -} - -#[derive(serde::Serialize, Debug)] -struct ConnectTokenPassword { - username: String, - password: String, -} - -#[derive(serde::Serialize, Debug)] -struct ConnectTokenAuthCode { - code: String, - code_verifier: String, - redirect_uri: String, -} - -#[derive(serde::Serialize, Debug)] -struct ConnectTokenClientCredentials { - username: String, - client_secret: String, -} - -#[derive(serde::Deserialize, Debug)] -struct ConnectTokenRes { - access_token: String, - refresh_token: String, - #[serde(rename = "Key", alias = "key")] - key: String, -} - -#[derive(serde::Deserialize, Debug)] -struct ConnectErrorRes { - error: String, - error_description: Option, - #[serde(rename = "ErrorModel", alias = "errorModel")] - error_model: Option, - #[serde(rename = "TwoFactorProviders", alias = "twoFactorProviders")] - two_factor_providers: Option>, - #[serde( - rename = "SsoEmail2faSessionToken", - alias = "ssoEmail2faSessionToken" - )] - sso_email_2fa_session_token: Option, -} - -#[derive(serde::Deserialize, Debug)] -struct ConnectErrorResErrorModel { - #[serde(rename = "Message", alias = "message")] - message: String, -} - -#[derive(serde::Serialize, Debug)] -struct ConnectRefreshTokenReq { - grant_type: String, - client_id: String, - refresh_token: String, -} - -#[derive(serde::Deserialize, Debug)] -struct ConnectRefreshTokenRes { - access_token: String, -} - -#[derive(serde::Serialize, Debug)] -struct SendEmailLoginReq { - email: String, - #[serde(rename = "DeviceIdentifier", alias = "deviceIdentifier")] - device_identifier: String, - #[serde( - rename = "SsoEmail2faSessionToken", - alias = "ssoEmail2faSessionToken" - )] - sso_email_2fa_session_token: String, -} - -#[derive(serde::Deserialize, Debug)] -struct SyncRes { - #[serde(rename = "Ciphers", alias = "ciphers")] - ciphers: Vec, - #[serde(rename = "Profile", alias = "profile")] - profile: SyncResProfile, - #[serde(rename = "Folders", alias = "folders")] - folders: Vec, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] -struct SyncResCipher { - #[serde(rename = "Id", alias = "id")] - id: String, - #[serde(rename = "FolderId", alias = "folderId")] - folder_id: Option, - #[serde(rename = "OrganizationId", alias = "organizationId")] - organization_id: Option, - #[serde(rename = "Name", alias = "name")] - name: String, - #[serde(rename = "Login", alias = "login")] - login: Option, - #[serde(rename = "Card", alias = "card")] - card: Option, - #[serde(rename = "Identity", alias = "identity")] - identity: Option, - #[serde(rename = "SecureNote", alias = "secureNote")] - secure_note: Option, - #[serde(rename = "SshKey", alias = "sshKey")] - ssh_key: Option, - #[serde(rename = "Notes", alias = "notes")] - notes: Option, - #[serde(rename = "PasswordHistory", alias = "passwordHistory")] - password_history: Option>, - #[serde(rename = "Fields", alias = "fields")] - fields: Option>, - #[serde(rename = "DeletedDate", alias = "deletedDate")] - deleted_date: Option, - #[serde(rename = "Key", alias = "key")] - key: Option, - #[serde(rename = "Reprompt", alias = "reprompt")] - reprompt: CipherRepromptType, -} - -impl SyncResCipher { - fn to_entry( - &self, - folders: &[SyncResFolder], - ) -> Option { - if self.deleted_date.is_some() { - return None; - } - let history = - self.password_history - .as_ref() - .map_or_else(Vec::new, |history| { - history - .iter() - .filter_map(|entry| { - // Gets rid of entries with a non-existent - // password - entry.password.clone().map(|p| { - crate::db::HistoryEntry { - last_used_date: entry - .last_used_date - .clone(), - password: p, - } - }) - }) - .collect() - }); - - let (folder, folder_id) = - self.folder_id.as_ref().map_or((None, None), |folder_id| { - let mut folder_name = None; - for folder in folders { - if &folder.id == folder_id { - folder_name = Some(folder.name.clone()); - } - } - (folder_name, Some(folder_id)) - }); - let data = if let Some(login) = &self.login { - crate::db::EntryData::Login { - username: login.username.clone(), - password: login.password.clone(), - totp: login.totp.clone(), - uris: login.uris.as_ref().map_or_else( - std::vec::Vec::new, - |uris| { - uris.iter() - .filter_map(|uri| { - uri.uri.clone().map(|s| crate::db::Uri { - uri: s, - match_type: uri.match_type, - }) - }) - .collect() - }, - ), - } - } else if let Some(card) = &self.card { - crate::db::EntryData::Card { - cardholder_name: card.cardholder_name.clone(), - number: card.number.clone(), - brand: card.brand.clone(), - exp_month: card.exp_month.clone(), - exp_year: card.exp_year.clone(), - code: card.code.clone(), - } - } else if let Some(identity) = &self.identity { - crate::db::EntryData::Identity { - title: identity.title.clone(), - first_name: identity.first_name.clone(), - middle_name: identity.middle_name.clone(), - last_name: identity.last_name.clone(), - address1: identity.address1.clone(), - address2: identity.address2.clone(), - address3: identity.address3.clone(), - city: identity.city.clone(), - state: identity.state.clone(), - postal_code: identity.postal_code.clone(), - country: identity.country.clone(), - phone: identity.phone.clone(), - email: identity.email.clone(), - ssn: identity.ssn.clone(), - license_number: identity.license_number.clone(), - passport_number: identity.passport_number.clone(), - username: identity.username.clone(), - } - } else if let Some(_secure_note) = &self.secure_note { - crate::db::EntryData::SecureNote - } else if let Some(ssh_key) = &self.ssh_key { - crate::db::EntryData::SshKey { - private_key: ssh_key.private_key.clone(), - public_key: ssh_key.public_key.clone(), - fingerprint: ssh_key.fingerprint.clone(), - } - } else { - return None; - }; - let fields = self.fields.as_ref().map_or_else(Vec::new, |fields| { - fields - .iter() - .map(|field| crate::db::Field { - ty: field.ty, - name: field.name.clone(), - value: field.value.clone(), - linked_id: field.linked_id, - }) - .collect() - }); - Some(crate::db::Entry { - id: self.id.clone(), - org_id: self.organization_id.clone(), - folder, - folder_id: folder_id.map(std::string::ToString::to_string), - name: self.name.clone(), - data, - fields, - notes: self.notes.clone(), - history, - key: self.key.clone(), - master_password_reprompt: self.reprompt, - }) - } -} - -#[derive(serde::Deserialize, Debug)] -struct SyncResProfile { - #[serde(rename = "Key", alias = "key")] - key: String, - #[serde(rename = "PrivateKey", alias = "privateKey")] - private_key: String, - #[serde(rename = "Organizations", alias = "organizations")] - organizations: Vec, -} - -#[derive(serde::Deserialize, Debug)] -struct SyncResProfileOrganization { - #[serde(rename = "Id", alias = "id")] - id: String, - #[serde(rename = "Key", alias = "key")] - key: String, -} - -#[derive(serde::Deserialize, Debug, Clone)] -struct SyncResFolder { - #[serde(rename = "Id", alias = "id")] - id: String, - #[serde(rename = "Name", alias = "name")] - name: String, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] -struct CipherLogin { - #[serde(rename = "Username", alias = "username")] - username: Option, - #[serde(rename = "Password", alias = "password")] - password: Option, - #[serde(rename = "Totp", alias = "totp")] - totp: Option, - #[serde(rename = "Uris", alias = "uris")] - uris: Option>, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] -struct CipherLoginUri { - #[serde(rename = "Uri", alias = "uri")] - uri: Option, - #[serde(rename = "Match", alias = "match")] - match_type: Option, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] -struct CipherCard { - #[serde(rename = "CardholderName", alias = "cardholderName")] - cardholder_name: Option, - #[serde(rename = "Number", alias = "number")] - number: Option, - #[serde(rename = "Brand", alias = "brand")] - brand: Option, - #[serde(rename = "ExpMonth", alias = "expMonth")] - exp_month: Option, - #[serde(rename = "ExpYear", alias = "expYear")] - exp_year: Option, - #[serde(rename = "Code", alias = "code")] - code: Option, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] -struct CipherIdentity { - #[serde(rename = "Title", alias = "title")] - title: Option, - #[serde(rename = "FirstName", alias = "firstName")] - first_name: Option, - #[serde(rename = "MiddleName", alias = "middleName")] - middle_name: Option, - #[serde(rename = "LastName", alias = "lastName")] - last_name: Option, - #[serde(rename = "Address1", alias = "address1")] - address1: Option, - #[serde(rename = "Address2", alias = "address2")] - address2: Option, - #[serde(rename = "Address3", alias = "address3")] - address3: Option, - #[serde(rename = "City", alias = "city")] - city: Option, - #[serde(rename = "State", alias = "state")] - state: Option, - #[serde(rename = "PostalCode", alias = "postalCode")] - postal_code: Option, - #[serde(rename = "Country", alias = "country")] - country: Option, - #[serde(rename = "Phone", alias = "phone")] - phone: Option, - #[serde(rename = "Email", alias = "email")] - email: Option, - #[serde(rename = "SSN", alias = "ssn")] - ssn: Option, - #[serde(rename = "LicenseNumber", alias = "licenseNumber")] - license_number: Option, - #[serde(rename = "PassportNumber", alias = "passportNumber")] - passport_number: Option, - #[serde(rename = "Username", alias = "username")] - username: Option, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] -struct CipherSshKey { - #[serde(rename = "PrivateKey", alias = "privateKey")] - private_key: Option, - #[serde(rename = "PublicKey", alias = "publicKey")] - public_key: Option, - #[serde(rename = "Fingerprint", alias = "keyFingerprint")] - fingerprint: Option, -} - -#[derive( - serde_repr::Serialize_repr, - serde_repr::Deserialize_repr, - Debug, - Clone, - Copy, - PartialEq, - Eq, -)] -#[repr(u16)] -pub enum FieldType { - Text = 0, - Hidden = 1, - Boolean = 2, - Linked = 3, -} - -#[derive( - serde_repr::Serialize_repr, - serde_repr::Deserialize_repr, - Debug, - Clone, - Copy, - PartialEq, - Eq, -)] -#[repr(u16)] -pub enum LinkedIdType { - LoginUsername = 100, - LoginPassword = 101, - CardCardholderName = 300, - CardExpMonth = 301, - CardExpYear = 302, - CardCode = 303, - CardBrand = 304, - CardNumber = 305, - IdentityTitle = 400, - IdentityMiddleName = 401, - IdentityAddress1 = 402, - IdentityAddress2 = 403, - IdentityAddress3 = 404, - IdentityCity = 405, - IdentityState = 406, - IdentityPostalCode = 407, - IdentityCountry = 408, - IdentityCompany = 409, - IdentityEmail = 410, - IdentityPhone = 411, - IdentitySsn = 412, - IdentityUsername = 413, - IdentityPassportNumber = 414, - IdentityLicenseNumber = 415, - IdentityFirstName = 416, - IdentityLastName = 417, - IdentityFullName = 418, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] -struct CipherField { - #[serde(rename = "Type", alias = "type")] - ty: Option, - #[serde(rename = "Name", alias = "name")] - name: Option, - #[serde(rename = "Value", alias = "value")] - value: Option, - #[serde(rename = "LinkedId", alias = "linkedId")] - linked_id: Option, -} - -// this is just a name and some notes, both of which are already on the cipher -// object -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] -struct CipherSecureNote {} - -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] -struct SyncResPasswordHistory { - #[serde(rename = "LastUsedDate", alias = "lastUsedDate")] - last_used_date: String, - #[serde(rename = "Password", alias = "password")] - password: Option, -} - -#[derive(serde::Serialize, Debug)] -struct CiphersPostReq { - #[serde(rename = "type")] - ty: u32, // XXX what are the valid types? - #[serde(rename = "folderId")] - folder_id: Option, - name: String, - notes: Option, - login: Option, - card: Option, - identity: Option, - #[serde(rename = "secureNote")] - secure_note: Option, -} - -#[derive(serde::Serialize, Debug)] -struct CiphersPutReq { - #[serde(rename = "type")] - ty: u32, // XXX what are the valid types? - #[serde(rename = "folderId")] - folder_id: Option, - #[serde(rename = "organizationId")] - organization_id: Option, - name: String, - notes: Option, - login: Option, - card: Option, - identity: Option, - fields: Vec, - #[serde(rename = "secureNote")] - secure_note: Option, - #[serde(rename = "passwordHistory")] - password_history: Vec, -} - -#[derive(serde::Serialize, Debug)] -struct CiphersPutReqHistory { - #[serde(rename = "LastUsedDate")] - last_used_date: String, - #[serde(rename = "Password")] - password: String, -} - -#[derive(serde::Deserialize, Debug)] -struct FoldersRes { - #[serde(rename = "Data", alias = "data")] - data: Vec, -} - -#[derive(serde::Deserialize, Debug)] -struct FoldersResData { - #[serde(rename = "Id", alias = "id")] - id: String, - #[serde(rename = "Name", alias = "name")] - name: String, -} - -#[derive(serde::Serialize, Debug)] -struct FoldersPostReq { - name: String, -} - -// Used for the Bitwarden-Client-Name header. Accepted values: -// https://github.com/bitwarden/server/blob/main/src/Core/Enums/BitwardenClient.cs -const BITWARDEN_CLIENT: &str = "cli"; - -// DeviceType.LinuxDesktop, as per Bitwarden API device types. -const DEVICE_TYPE: u8 = 8; - -#[derive(Debug)] -pub struct Client { - base_url: String, - identity_url: String, - ui_url: String, - client_cert_path: Option, -} - -impl Client { - pub fn new( - base_url: &str, - identity_url: &str, - ui_url: &str, - client_cert_path: Option<&std::path::Path>, - ) -> Self { - Self { - base_url: base_url.to_string(), - identity_url: identity_url.to_string(), - ui_url: ui_url.to_string(), - client_cert_path: client_cert_path - .map(std::path::Path::to_path_buf), - } - } - - async fn reqwest_client(&self) -> Result { - let mut default_headers = axum::http::HeaderMap::new(); - default_headers.insert( - "Bitwarden-Client-Name", - axum::http::HeaderValue::from_static(BITWARDEN_CLIENT), - ); - default_headers.insert( - "Bitwarden-Client-Version", - axum::http::HeaderValue::from_static(env!("CARGO_PKG_VERSION")), - ); - default_headers.append( - "Device-Type", - // unwrap is safe here because DEVICE_TYPE is a number and digits - // are valid ASCII - axum::http::HeaderValue::from_str(&DEVICE_TYPE.to_string()) - .unwrap(), - ); - let user_agent = format!( - "{}/{}", - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION") - ); - if let Some(client_cert_path) = self.client_cert_path.as_ref() { - let mut buf = Vec::new(); - let mut f = tokio::fs::File::open(client_cert_path) - .await - .map_err(|e| Error::LoadClientCert { - source: e, - file: client_cert_path.clone(), - })?; - f.read_to_end(&mut buf).await.map_err(|e| { - Error::LoadClientCert { - source: e, - file: client_cert_path.clone(), - } - })?; - let pem = reqwest::Identity::from_pem(&buf) - .map_err(|e| Error::CreateReqwestClient { source: e })?; - Ok(reqwest::Client::builder() - .user_agent(user_agent) - .identity(pem) - .default_headers(default_headers) - .build() - .map_err(|e| Error::CreateReqwestClient { source: e })?) - } else { - Ok(reqwest::Client::builder() - .user_agent(user_agent) - .default_headers(default_headers) - .build() - .map_err(|e| Error::CreateReqwestClient { source: e })?) - } - } - - pub async fn prelogin( - &self, - email: &str, - ) -> Result<(KdfType, u32, Option, Option)> { - let prelogin = PreloginReq { - email: email.to_string(), - }; - let client = self.reqwest_client().await?; - let res = client - .post(self.identity_url("/accounts/prelogin")) - .json(&prelogin) - .send() - .await - .map_err(|source| Error::Reqwest { source })?; - let prelogin_res: PreloginRes = res.json_with_path().await?; - Ok(( - prelogin_res.kdf, - prelogin_res.kdf_iterations, - prelogin_res.kdf_memory, - prelogin_res.kdf_parallelism, - )) - } - - pub async fn register( - &self, - email: &str, - device_id: &str, - apikey: &crate::locked::ApiKey, - ) -> Result<()> { - let connect_req = ConnectTokenReq { - auth: ConnectTokenAuth::ClientCredentials( - ConnectTokenClientCredentials { - username: email.to_string(), - client_secret: String::from_utf8( - apikey.client_secret().to_vec(), - ) - .unwrap(), - }, - ), - grant_type: "client_credentials".to_string(), - scope: "api".to_string(), - // XXX unwraps here are not necessarily safe - client_id: String::from_utf8(apikey.client_id().to_vec()) - .unwrap(), - device_type: u32::from(DEVICE_TYPE), - device_identifier: device_id.to_string(), - device_name: "rbw".to_string(), - device_push_token: String::new(), - two_factor_token: None, - two_factor_provider: None, - }; - let client = self.reqwest_client().await?; - let res = client - .post(self.identity_url("/connect/token")) - .form(&connect_req) - .send() - .await - .map_err(|source| Error::Reqwest { source })?; - if res.status() == reqwest::StatusCode::OK { - Ok(()) - } else { - let code = res.status().as_u16(); - match res.text().await { - Ok(body) => match body.clone().json_with_path() { - Ok(json) => Err(classify_login_error(&json, code)), - Err(e) => { - log::warn!("{e}: {body}"); - Err(Error::RequestFailed { status: code }) - } - }, - Err(e) => { - log::warn!("failed to read response body: {e}"); - Err(Error::RequestFailed { status: code }) - } - } - } - } - - pub async fn login( - &self, - email: &str, - sso_id: Option<&str>, - device_id: &str, - password_hash: &crate::locked::PasswordHash, - two_factor_token: Option<&str>, - two_factor_provider: Option, - ) -> Result<(String, String, String)> { - let connect_req = match sso_id { - Some(sso_id) => { - let (sso_code, sso_code_verifier, callback_url) = - self.obtain_sso_code(sso_id).await?; - - ConnectTokenReq { - auth: ConnectTokenAuth::AuthCode(ConnectTokenAuthCode { - code: sso_code, - code_verifier: sso_code_verifier, - redirect_uri: callback_url, - }), - grant_type: "authorization_code".to_string(), - scope: "api offline_access".to_string(), - client_id: "cli".to_string(), - device_type: u32::from(DEVICE_TYPE), - device_identifier: device_id.to_string(), - device_name: "rbw".to_string(), - device_push_token: String::new(), - two_factor_token: two_factor_token - .map(std::string::ToString::to_string), - two_factor_provider: two_factor_provider - .map(|ty| ty as u32), - } - } - None => ConnectTokenReq { - auth: ConnectTokenAuth::Password(ConnectTokenPassword { - username: email.to_string(), - password: crate::base64::encode(password_hash.hash()), - }), - - grant_type: "password".to_string(), - scope: "api offline_access".to_string(), - client_id: "cli".to_string(), - device_type: 8, - device_identifier: device_id.to_string(), - device_name: "rbw".to_string(), - device_push_token: String::new(), - two_factor_token: two_factor_token - .map(std::string::ToString::to_string), - two_factor_provider: two_factor_provider.map(|ty| ty as u32), - }, - }; - - let client = self.reqwest_client().await?; - let res = client - .post(self.identity_url("/connect/token")) - .form(&connect_req) - .header( - "auth-email", - crate::base64::encode_url_safe_no_pad(email), - ) - .send() - .await - .map_err(|source| Error::Reqwest { source })?; - - if res.status() == reqwest::StatusCode::OK { - let connect_res: ConnectTokenRes = res.json_with_path().await?; - Ok(( - connect_res.access_token, - connect_res.refresh_token, - connect_res.key, - )) - } else { - let code = res.status().as_u16(); - match res.text().await { - Ok(body) => match body.clone().json_with_path() { - Ok(json) => Err(classify_login_error(&json, code)), - Err(e) => { - log::warn!("{e}: {body}"); - Err(Error::RequestFailed { status: code }) - } - }, - Err(e) => { - log::warn!("failed to read response body: {e}"); - Err(Error::RequestFailed { status: code }) - } - } - } - } - - pub async fn send_email_login( - &self, - email: &str, - device_id: &str, - sso_email_2fa_session_token: &str, - ) -> Result<()> { - let send_email_login_req = SendEmailLoginReq { - email: email.to_string(), - device_identifier: device_id.to_string(), - sso_email_2fa_session_token: sso_email_2fa_session_token - .to_string(), - }; - - let client = self.reqwest_client().await?; - let res = client - .post(self.api_url("/two-factor/send-email-login")) - .json(&send_email_login_req) - .header( - "auth-email", - crate::base64::encode_url_safe_no_pad(email), - ) - .send() - .await - .map_err(|source| Error::Reqwest { source })?; - - if res.status() == reqwest::StatusCode::OK { - Ok(()) - } else { - let code = res.status().as_u16(); - log::warn!("{code}: {:?}", res.text().await); - Err(Error::RequestFailed { status: code }) - } - } - - async fn obtain_sso_code( - &self, - sso_id: &str, - ) -> Result<(String, String, String)> { - let state = - rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); - let sso_code_verifier = - rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); - - let mut hasher = sha2::Sha256::new(); - hasher.update(sso_code_verifier.clone()); - let code_challenge = - crate::base64::encode_url_safe_no_pad(hasher.finalize()); - - let port = find_free_port(8065, 8070).await?; - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) - .await - .map_err(|e| Error::CreateSSOCallbackServer { err: e })?; - - let callback_server = - start_sso_callback_server(listener, state.as_str()); - - let callback_url = - "http://localhost:".to_string() + port.to_string().as_str(); - - open::that( - self.ui_url.clone() - + "/#/sso?clientId=" - + "cli" - + "&redirectUri=" - + urlencoding::encode(callback_url.as_str()) - .into_owned() - .as_str() - + "&state=" - + state.as_str() - + "&codeChallenge=" - + code_challenge.as_str() - + "&identifier=" - + sso_id, - ) - .map_err(|e| Error::FailedToOpenWebBrowser { err: e })?; - // TODO: probably it'd be better to display the URL in the console if the automatic - // open operation fails, instead of failing the whole process? E.g. docker container - // case - - let sso_code = callback_server.await?; - - Ok((sso_code, sso_code_verifier, callback_url)) - } - - pub async fn sync( - &self, - access_token: &str, - ) -> Result<( - String, - String, - std::collections::HashMap, - Vec, - )> { - let client = self.reqwest_client().await?; - let res = client - .get(self.api_url("/sync")) - .header("Authorization", format!("Bearer {access_token}")) - // This is necessary for vaultwarden to include the ssh keys in the response - .header("Bitwarden-Client-Version", "2024.12.0") - .send() - .await - .map_err(|source| Error::Reqwest { source })?; - match res.status() { - reqwest::StatusCode::OK => { - let sync_res: SyncRes = res.json_with_path().await?; - let folders = sync_res.folders.clone(); - let ciphers = sync_res - .ciphers - .iter() - .filter_map(|cipher| cipher.to_entry(&folders)) - .collect(); - let org_keys = sync_res - .profile - .organizations - .iter() - .map(|org| (org.id.clone(), org.key.clone())) - .collect(); - Ok(( - sync_res.profile.key, - sync_res.profile.private_key, - org_keys, - ciphers, - )) - } - reqwest::StatusCode::UNAUTHORIZED => { - Err(Error::RequestUnauthorized) - } - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } - } - - pub fn add( - &self, - access_token: &str, - name: &str, - data: &crate::db::EntryData, - notes: Option<&str>, - folder_id: Option<&str>, - ) -> Result<()> { - let mut req = CiphersPostReq { - ty: 1, - folder_id: folder_id.map(std::string::ToString::to_string), - name: name.to_string(), - notes: notes.map(std::string::ToString::to_string), - login: None, - card: None, - identity: None, - secure_note: None, - }; - match data { - crate::db::EntryData::Login { - username, - password, - totp, - uris, - } => { - let uris = if uris.is_empty() { - None - } else { - Some( - uris.iter() - .map(|s| CipherLoginUri { - uri: Some(s.uri.clone()), - match_type: s.match_type, - }) - .collect(), - ) - }; - req.login = Some(CipherLogin { - username: username.clone(), - password: password.clone(), - totp: totp.clone(), - uris, - }); - } - crate::db::EntryData::Card { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - } => { - req.card = Some(CipherCard { - cardholder_name: cardholder_name.clone(), - number: number.clone(), - brand: brand.clone(), - exp_month: exp_month.clone(), - exp_year: exp_year.clone(), - code: code.clone(), - }); - } - crate::db::EntryData::Identity { - title, - first_name, - middle_name, - last_name, - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - } => { - req.identity = Some(CipherIdentity { - title: title.clone(), - first_name: first_name.clone(), - middle_name: middle_name.clone(), - last_name: last_name.clone(), - address1: address1.clone(), - address2: address2.clone(), - address3: address3.clone(), - city: city.clone(), - state: state.clone(), - postal_code: postal_code.clone(), - country: country.clone(), - phone: phone.clone(), - email: email.clone(), - ssn: ssn.clone(), - license_number: license_number.clone(), - passport_number: passport_number.clone(), - username: username.clone(), - }); - } - crate::db::EntryData::SecureNote => { - req.secure_note = Some(CipherSecureNote {}); - } - crate::db::EntryData::SshKey { .. } => unreachable!(), - } - let client = reqwest::blocking::Client::new(); - let res = client - .post(self.api_url("/ciphers")) - .header("Authorization", format!("Bearer {access_token}")) - .json(&req) - .send() - .map_err(|source| Error::Reqwest { source })?; - match res.status() { - reqwest::StatusCode::OK => Ok(()), - reqwest::StatusCode::UNAUTHORIZED => { - Err(Error::RequestUnauthorized) - } - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } - } - - pub fn edit( - &self, - access_token: &str, - id: &str, - org_id: Option<&str>, - name: &str, - data: &crate::db::EntryData, - fields: &[crate::db::Field], - notes: Option<&str>, - folder_uuid: Option<&str>, - history: &[crate::db::HistoryEntry], - ) -> Result<()> { - let mut req = CiphersPutReq { - ty: match data { - crate::db::EntryData::Login { .. } => 1, - crate::db::EntryData::SecureNote => 2, - crate::db::EntryData::Card { .. } => 3, - crate::db::EntryData::Identity { .. } => 4, - crate::db::EntryData::SshKey { .. } => unreachable!(), - }, - folder_id: folder_uuid.map(std::string::ToString::to_string), - organization_id: org_id.map(std::string::ToString::to_string), - name: name.to_string(), - notes: notes.map(std::string::ToString::to_string), - login: None, - card: None, - identity: None, - secure_note: None, - fields: fields - .iter() - .map(|field| CipherField { - ty: field.ty, - name: field.name.clone(), - value: field.value.clone(), - linked_id: field.linked_id, - }) - .collect(), - password_history: history - .iter() - .map(|entry| CiphersPutReqHistory { - last_used_date: entry.last_used_date.clone(), - password: entry.password.clone(), - }) - .collect(), - }; - match data { - crate::db::EntryData::Login { - username, - password, - totp, - uris, - } => { - let uris = if uris.is_empty() { - None - } else { - Some( - uris.iter() - .map(|s| CipherLoginUri { - uri: Some(s.uri.clone()), - match_type: s.match_type, - }) - .collect(), - ) - }; - req.login = Some(CipherLogin { - username: username.clone(), - password: password.clone(), - totp: totp.clone(), - uris, - }); - } - crate::db::EntryData::Card { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - } => { - req.card = Some(CipherCard { - cardholder_name: cardholder_name.clone(), - number: number.clone(), - brand: brand.clone(), - exp_month: exp_month.clone(), - exp_year: exp_year.clone(), - code: code.clone(), - }); - } - crate::db::EntryData::Identity { - title, - first_name, - middle_name, - last_name, - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - } => { - req.identity = Some(CipherIdentity { - title: title.clone(), - first_name: first_name.clone(), - middle_name: middle_name.clone(), - last_name: last_name.clone(), - address1: address1.clone(), - address2: address2.clone(), - address3: address3.clone(), - city: city.clone(), - state: state.clone(), - postal_code: postal_code.clone(), - country: country.clone(), - phone: phone.clone(), - email: email.clone(), - ssn: ssn.clone(), - license_number: license_number.clone(), - passport_number: passport_number.clone(), - username: username.clone(), - }); - } - crate::db::EntryData::SecureNote => { - req.secure_note = Some(CipherSecureNote {}); - } - crate::db::EntryData::SshKey { .. } => unreachable!(), - } - let client = reqwest::blocking::Client::new(); - let res = client - .put(self.api_url(&format!("/ciphers/{id}"))) - .header("Authorization", format!("Bearer {access_token}")) - .json(&req) - .send() - .map_err(|source| Error::Reqwest { source })?; - match res.status() { - reqwest::StatusCode::OK => Ok(()), - reqwest::StatusCode::UNAUTHORIZED => { - Err(Error::RequestUnauthorized) - } - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } - } - - pub fn remove(&self, access_token: &str, id: &str) -> Result<()> { - let client = reqwest::blocking::Client::new(); - let res = client - .delete(self.api_url(&format!("/ciphers/{id}"))) - .header("Authorization", format!("Bearer {access_token}")) - .send() - .map_err(|source| Error::Reqwest { source })?; - match res.status() { - reqwest::StatusCode::OK => Ok(()), - reqwest::StatusCode::UNAUTHORIZED => { - Err(Error::RequestUnauthorized) - } - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } - } - - pub fn folders( - &self, - access_token: &str, - ) -> Result> { - let client = reqwest::blocking::Client::new(); - let res = client - .get(self.api_url("/folders")) - .header("Authorization", format!("Bearer {access_token}")) - .send() - .map_err(|source| Error::Reqwest { source })?; - match res.status() { - reqwest::StatusCode::OK => { - let folders_res: FoldersRes = res.json_with_path()?; - Ok(folders_res - .data - .iter() - .map(|folder| (folder.id.clone(), folder.name.clone())) - .collect()) - } - reqwest::StatusCode::UNAUTHORIZED => { - Err(Error::RequestUnauthorized) - } - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } - } - - pub fn create_folder( - &self, - access_token: &str, - name: &str, - ) -> Result { - let req = FoldersPostReq { - name: name.to_string(), - }; - let client = reqwest::blocking::Client::new(); - let res = client - .post(self.api_url("/folders")) - .header("Authorization", format!("Bearer {access_token}")) - .json(&req) - .send() - .map_err(|source| Error::Reqwest { source })?; - match res.status() { - reqwest::StatusCode::OK => { - let folders_res: FoldersResData = res.json_with_path()?; - Ok(folders_res.id) - } - reqwest::StatusCode::UNAUTHORIZED => { - Err(Error::RequestUnauthorized) - } - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } - } - - pub fn exchange_refresh_token( - &self, - refresh_token: &str, - ) -> Result { - let connect_req = ConnectRefreshTokenReq { - grant_type: "refresh_token".to_string(), - client_id: "cli".to_string(), - refresh_token: refresh_token.to_string(), - }; - let client = reqwest::blocking::Client::new(); - let res = client - .post(self.identity_url("/connect/token")) - .form(&connect_req) - .send() - .map_err(|source| Error::Reqwest { source })?; - let connect_res: ConnectRefreshTokenRes = res.json_with_path()?; - Ok(connect_res.access_token) - } - - pub async fn exchange_refresh_token_async( - &self, - refresh_token: &str, - ) -> Result { - let connect_req = ConnectRefreshTokenReq { - grant_type: "refresh_token".to_string(), - client_id: "cli".to_string(), - refresh_token: refresh_token.to_string(), - }; - let client = self.reqwest_client().await?; - let res = client - .post(self.identity_url("/connect/token")) - .form(&connect_req) - .send() - .await - .map_err(|source| Error::Reqwest { source })?; - let connect_res: ConnectRefreshTokenRes = - res.json_with_path().await?; - Ok(connect_res.access_token) - } - - fn api_url(&self, path: &str) -> String { - format!("{}{}", self.base_url, path) - } - - fn identity_url(&self, path: &str) -> String { - format!("{}{}", self.identity_url, path) - } -} - -async fn find_free_port(bottom: u16, top: u16) -> Result { - for port in bottom..top { - if tokio::net::TcpListener::bind(("127.0.0.1", port)) - .await - .is_ok() - { - return Ok(port); - } - } - - Err(Error::FailedToFindFreePort { - range: format!("({bottom}..{top})"), - }) -} - -#[derive(Clone)] -struct SSOHandlerState { - state: String, - sender: tokio::sync::mpsc::Sender>, -} - -async fn start_sso_callback_server( - listener: tokio::net::TcpListener, - state: &str, -) -> Result { - let (shut_sender, shut_receiver) = tokio::sync::mpsc::channel(1); - let (sender, mut receiver) = tokio::sync::mpsc::channel(1); - - let sso_handler_state = std::sync::Arc::new(SSOHandlerState { - state: state.to_string(), - sender: shut_sender, - }); - - let app = axum::Router::new() - .route("/", axum::routing::get(handle_sso_callback)) - .with_state(sso_handler_state); - - axum::serve(listener, app) - .with_graceful_shutdown(sso_server_graceful_shutdown( - sender, - shut_receiver, - )) - .await - .map_err(|e| Error::FailedToProcessSSOCallback { - msg: e.to_string(), - })?; - - receiver.recv().await.unwrap() -} - -async fn sso_server_graceful_shutdown( - sender: tokio::sync::mpsc::Sender>, - mut receiver: tokio::sync::mpsc::Receiver>, -) { - sender.send(receiver.recv().await.unwrap()).await.unwrap(); -} - -async fn handle_sso_callback( - axum::extract::State(state): axum::extract::State< - std::sync::Arc, - >, - axum::extract::Query(params): axum::extract::Query< - std::collections::HashMap, - >, -) -> axum::http::Response { - match sso_query_code(¶ms, state.state.as_str()) { - Ok(sso_code) => { - state.sender.send(Ok(sso_code)).await.unwrap(); - - axum::http::Response::builder().status(axum::http::StatusCode::OK). - body( - "Success | rbw \ -

Successfully authenticated with rbw

\ -

You may now close this tab and return to the terminal.

\ - ".to_string()).unwrap() - } - Err(e) => { - state.sender.send(Err(e)).await.unwrap(); - - axum::http::Response::builder().status(axum::http::StatusCode::BAD_REQUEST). - body( - "Failed | rbw \ -

Something went wrong logging into the rbw

\ -

You may now close this tab and return to the terminal.

\ - ".to_string()).unwrap() - } - } -} - -fn sso_query_code( - params: &std::collections::HashMap, - state: &str, -) -> Result { - let sso_code = - params - .get("code") - .ok_or(Error::FailedToProcessSSOCallback { - msg: "Could not obtain code from the URL".to_string(), - })?; - - let received_state = - params - .get("state") - .ok_or(Error::FailedToProcessSSOCallback { - msg: "Could not obtain state from the URL".to_string(), - })?; - - if received_state.split("_identifier=").next().unwrap() != state { - return Err(Error::FailedToProcessSSOCallback { - msg: format!("SSO callback states do not match, sent: {state}, received: {received_state}"), - }); - } - - Ok(sso_code.clone()) -} - -fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { - let error_desc = error_res.error_description.clone(); - let error_desc = error_desc.as_deref(); - match error_res.error.as_str() { - "invalid_grant" => match error_desc { - Some("invalid_username_or_password") => { - if let Some(error_model) = error_res.error_model.as_ref() { - let message = error_model.message.as_str().to_string(); - return Error::IncorrectPassword { message }; - } - } - Some("Two factor required.") => { - if let Some(providers) = - error_res.two_factor_providers.as_ref() - { - return Error::TwoFactorRequired { - providers: providers.clone(), - sso_email_2fa_session_token: error_res - .sso_email_2fa_session_token - .clone(), - }; - } - } - Some("Captcha required.") => { - return Error::RegistrationRequired; - } - _ => {} - }, - "invalid_client" => { - return Error::IncorrectApiKey; - } - "" => { - // bitwarden_rs returns an empty error and error_description for - // this case, for some reason - if error_desc.is_none() || error_desc == Some("") { - if let Some(error_model) = error_res.error_model.as_ref() { - let message = error_model.message.as_str().to_string(); - match message.as_str() { - "Username or password is incorrect. Try again" - | "TOTP code is not a number" => { - return Error::IncorrectPassword { message }; - } - s => { - if s.starts_with( - "Invalid TOTP code! Server time: ", - ) { - return Error::IncorrectPassword { message }; - } - } - } - } - } - } - _ => {} - } - - log::warn!("unexpected error received during login: {error_res:?}"); - Error::RequestFailed { status: code } -} diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 00000000..6bbc0579 --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,623 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, +}; + +use rand::distr::SampleString as _; +use sha2::Digest as _; +use tokio::sync::mpsc::{channel, Sender}; + +use crate::{ + actions::CryptoParameters, + api::{ + entry_data_type, CiphersPostReq, CiphersPutReq, ConnectErrorRes, ConnectRefreshTokenRes, + ConnectTokenAuth, ConnectTokenReq, ConnectTokenRes, FoldersRes, FoldersResData, + PreloginRes, SyncRes, TwoFactorProviderType, + }, + db::{Encrypted, Entry, EntryData}, + error::{Error, Result}, + json::{DeserializeJsonWithPath as _, DeserializeJsonWithPathAsync as _}, +}; + +// Used for the Bitwarden-Client-Name header. Accepted values: +// https://github.com/bitwarden/server/blob/main/src/Core/Enums/BitwardenClient.cs +const BITWARDEN_CLIENT: &str = "cli"; + +// DeviceType.LinuxDesktop, as per Bitwarden API device types. +const DEVICE_TYPE: u8 = 8; + +enum ClientRequest<'a> { + Prelogin(&'a str), + ConnectToken(ConnectTokenReq<'a>), + Login(ConnectTokenReq<'a>, &'a str), + SendEmailLogin(&'a str, &'a str, &'a str), + Sync(&'a str), + ExchangeRefreshToken(&'a str), + Add(&'a str, CiphersPostReq), + Edit(&'a str, &'a str, CiphersPutReq), + Remove(&'a str, &'a str), + Folders(&'a str), + CreateFolder(&'a str, &'a str), +} + +impl<'a> ClientRequest<'a> { + async fn req(self, client: &Client) -> Result { + let http_client = client.reqwest_client().await?; + + let rb = match self { + Self::Prelogin(email) => http_client + .post(client.identity_url("/accounts/prelogin")) + .json(&serde_json::json!({"email": email})), + Self::ConnectToken(r) => http_client + .post(client.identity_url("/connect/token")) + .form(&r), + Self::Login(r, email) => http_client + .post(client.identity_url("/connect/token")) + .form(&r) + .header("auth-email", crate::base64::encode_url_safe_no_pad(email)), + Self::SendEmailLogin(email, device_identifier, sso_email_2fa_session_token) => { + http_client + .post(client.api_url("/two-factor/send-email-login")) + .json(&serde_json::json!({ + "email": email, + "DeviceIdentifier": device_identifier, + "SsoEmail2faSessionToken": sso_email_2fa_session_token + })) + .header("auth-email", crate::base64::encode_url_safe_no_pad(email)) + } + Self::Sync(access_token) => http_client + .get(client.api_url("/sync")) + .header("Authorization", format!("Bearer {access_token}")) + // This is necessary for vaultwarden to include the ssh keys in the response + .header("Bitwarden-Client-Version", "2024.12.0"), + Self::ExchangeRefreshToken(refresh_token) => http_client + .post(client.identity_url("/connect/token")) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", "cli"), + ("refresh_token", refresh_token), + ]), + Self::Add(access_token, r) => http_client + .post(client.api_url("/ciphers")) + .header("Authorization", format!("Bearer {access_token}")) + .json(&r), + Self::Edit(access_token, id, r) => http_client + .put(client.api_url(&format!("/ciphers/{id}"))) + .header("Authorization", format!("Bearer {access_token}")) + .json(&r), + Self::Remove(access_token, id) => http_client + .delete(client.api_url(&format!("/ciphers/{id}"))) + .header("Authorization", format!("Bearer {access_token}")), + Self::Folders(access_token) => http_client + .get(client.api_url("/folders")) + .header("Authorization", format!("Bearer {access_token}")), + Self::CreateFolder(access_token, name) => http_client + .post(client.api_url("/folders")) + .header("Authorization", format!("Bearer {access_token}")) + .json(&serde_json::json!({"name": name})), + }; + + Ok(rb.send().await?) + } +} + +async fn find_free_port(bottom: u16, top: u16) -> Result { + for port in bottom..top { + if tokio::net::TcpListener::bind(("127.0.0.1", port)) + .await + .is_ok() + { + return Ok(port); + } + } + + Err(Error::FailedToFindFreePort { + range: format!("({bottom}..{top})"), + }) +} + +#[derive(Clone)] +struct SSOHandlerState { + state: String, + sender: Sender>, +} + +async fn start_sso_callback_server( + listener: tokio::net::TcpListener, + state: &str, +) -> Result { + let (shut_tx, mut shut_rx) = channel(1); + let (tx, mut rx) = channel(1); + + let sso_handler_state = Arc::new(SSOHandlerState { + state: state.to_string(), + sender: shut_tx, + }); + + let app = axum::Router::new() + .route("/", axum::routing::get(handle_sso_callback)) + .with_state(sso_handler_state); + + axum::serve(listener, app) + .with_graceful_shutdown( + async move { tx.send(shut_rx.recv().await.unwrap()).await.unwrap() }, + ) + .await + .map_err(|e| Error::FailedToProcessSSOCallback { msg: e.to_string() })?; + + rx.recv().await.unwrap() +} + +async fn handle_sso_callback( + axum::extract::State(state): axum::extract::State>, + axum::extract::Query(params): axum::extract::Query>, +) -> axum::http::Response { + match sso_query_code(¶ms, state.state.as_str()) { + Ok(sso_code) => { + state.sender.send(Ok(sso_code)).await.unwrap(); + + axum::http::Response::builder() + .status(axum::http::StatusCode::OK) + .body( + "Success | rbw \ +

Successfully authenticated with rbw

\ +

You may now close this tab and return to the terminal.

\ + " + .to_string(), + ) + .unwrap() + } + Err(e) => { + state.sender.send(Err(e)).await.unwrap(); + + axum::http::Response::builder() + .status(axum::http::StatusCode::BAD_REQUEST) + .body( + "Failed | rbw \ +

Something went wrong logging into the rbw

\ +

You may now close this tab and return to the terminal.

\ + " + .to_string(), + ) + .unwrap() + } + } +} + +fn sso_query_code(params: &HashMap, state: &str) -> Result { + let sso_code = params + .get("code") + .ok_or(Error::FailedToProcessSSOCallback { + msg: "Could not obtain code from the URL".to_string(), + })?; + + let received_state = params + .get("state") + .ok_or(Error::FailedToProcessSSOCallback { + msg: "Could not obtain state from the URL".to_string(), + })?; + + if received_state.split("_identifier=").next().unwrap() != state { + return Err(Error::FailedToProcessSSOCallback { + msg: format!( + "SSO callback states do not match, sent: {state}, received: {received_state}" + ), + }); + } + + Ok(sso_code.clone()) +} + +#[derive(Debug)] +pub struct Client { + base_url: String, + identity_url: String, + ui_url: String, + client_cert_path: Option, +} + +impl Client { + pub fn new( + base_url: &str, + identity_url: &str, + ui_url: &str, + client_cert_path: Option<&Path>, + ) -> Self { + Self { + base_url: base_url.to_string(), + identity_url: identity_url.to_string(), + ui_url: ui_url.to_string(), + client_cert_path: client_cert_path.map(Path::to_path_buf), + } + } + + pub(super) async fn reqwest_client(&self) -> Result { + let mut default_headers = axum::http::HeaderMap::new(); + default_headers.insert( + "Bitwarden-Client-Name", + axum::http::HeaderValue::from_static(BITWARDEN_CLIENT), + ); + default_headers.insert( + "Bitwarden-Client-Version", + axum::http::HeaderValue::from_static(env!("CARGO_PKG_VERSION")), + ); + default_headers.append( + "Device-Type", + // unwrap is safe here because DEVICE_TYPE is a number and digits + // are valid ASCII + axum::http::HeaderValue::from_str(&DEVICE_TYPE.to_string()).unwrap(), + ); + let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); + if let Some(client_cert_path) = self.client_cert_path.as_ref() { + let buf = + tokio::fs::read(client_cert_path) + .await + .map_err(|e| Error::LoadClientCert { + source: e, + file: client_cert_path.clone(), + })?; + let pem = reqwest::Identity::from_pem(&buf) + .map_err(|e| Error::CreateReqwestClient { source: e })?; + Ok(reqwest::Client::builder() + .user_agent(user_agent) + .identity(pem) + .default_headers(default_headers) + .build() + .map_err(|e| Error::CreateReqwestClient { source: e })?) + } else { + Ok(reqwest::Client::builder() + .user_agent(user_agent) + .default_headers(default_headers) + .build() + .map_err(|e| Error::CreateReqwestClient { source: e })?) + } + } + + pub async fn prelogin(&self, email: &str) -> Result { + let res: PreloginRes = ClientRequest::Prelogin(email) + .req(self) + .await? + .json_with_path() + .await?; + + Ok(CryptoParameters { + kdf: res.kdf, + iterations: res.kdf_iterations, + memory: res.kdf_memory, + parallelism: res.kdf_parallelism, + }) + } + + async fn check_connect_token_res(res: reqwest::Response) -> Result { + match res.status() { + reqwest::StatusCode::OK => Ok(res), + status => match res.text().await { + Ok(body) => match body.clone().json_with_path::() { + Ok(err) => match err.try_into() { + Ok(e) => Err(e), + Err(err) => { + log::warn!("unexpected error received during login: {err:?}"); + Err(Error::RequestFailed { + status: status.as_u16(), + }) + } + }, + Err(e) => { + log::warn!("{e}: {body}"); + Err(Error::RequestFailed { + status: status.as_u16(), + }) + } + }, + Err(e) => { + log::warn!("failed to read response body: {e}"); + Err(Error::RequestFailed { + status: status.as_u16(), + }) + } + }, + } + } + + pub async fn register( + &self, + email: &str, + device_id: &str, + apikey: &crate::locked::ApiKey, + ) -> Result<()> { + let connect_req = ConnectTokenReq { + auth: ConnectTokenAuth::ClientCredentials { + username: email, + client_secret: std::str::from_utf8(apikey.client_secret()).unwrap(), + }, + grant_type: "client_credentials", + scope: "api", + // XXX unwraps here are not necessarily safe + client_id: std::str::from_utf8(apikey.client_id()).unwrap(), + device_type: u32::from(DEVICE_TYPE), + device_identifier: device_id, + device_name: "rbw", + device_push_token: "", + two_factor_token: None, + two_factor_provider: None, + }; + + let res = ClientRequest::ConnectToken(connect_req).req(self).await?; + + Self::check_connect_token_res(res).await?; + + Ok(()) + } + + pub async fn login( + &self, + email: &str, + sso_id: Option<&str>, + device_id: &str, + password_hash: &crate::locked::PasswordHash, + two_factor_token: Option<&str>, + two_factor_provider: Option, + ) -> Result<(String, String, String)> { + let (auth, grant_type, scope) = match sso_id { + Some(sso_id) => { + let (sso_code, sso_code_verifier, callback_url) = + self.obtain_sso_code(sso_id).await?; + ( + ConnectTokenAuth::AuthCode { + code: &sso_code.clone(), + code_verifier: &sso_code_verifier.clone(), + redirect_uri: &callback_url.clone(), + }, + "authorization_code", + "api offline_access", + ) + } + None => ( + ConnectTokenAuth::Password { + username: email, + password: &crate::base64::encode(password_hash.hash()), + }, + "password", + "api offline_access", + ), + }; + + let connect_req = ConnectTokenReq { + auth, + grant_type, + scope, + client_id: "cli", + device_type: u32::from(DEVICE_TYPE), + device_identifier: device_id, + device_name: "rbw", + device_push_token: "", + two_factor_token, + two_factor_provider: two_factor_provider.map(|ty| ty as u32), + }; + + let res = ClientRequest::Login(connect_req, email).req(self).await?; + + let res = Self::check_connect_token_res(res).await?; + + let connect_res: ConnectTokenRes = res.json_with_path().await?; + + Ok(( + connect_res.access_token, + connect_res.refresh_token, + connect_res.key, + )) + } + + pub async fn send_email_login( + &self, + email: &str, + device_id: &str, + sso_email_2fa_session_token: &str, + ) -> Result<()> { + let res = ClientRequest::SendEmailLogin(email, device_id, sso_email_2fa_session_token) + .req(self) + .await?; + + if res.status() == reqwest::StatusCode::OK { + Ok(()) + } else { + let code = res.status().as_u16(); + log::warn!("{code}: {:?}", res.text().await); + Err(Error::RequestFailed { status: code }) + } + } + + async fn obtain_sso_code(&self, sso_id: &str) -> Result<(String, String, String)> { + let state = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); + let sso_code_verifier = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); + + let mut hasher = sha2::Sha256::new(); + hasher.update(&sso_code_verifier); + let code_challenge = crate::base64::encode_url_safe_no_pad(hasher.finalize()); + + let port = find_free_port(8065, 8070).await?; + + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) + .await + .map_err(|e| Error::CreateSSOCallbackServer { err: e })?; + + let callback_server = start_sso_callback_server(listener, state.as_str()); + + let callback_url = "http://localhost:".to_string() + port.to_string().as_str(); + + open::that( + self.ui_url.clone() + + "/#/sso?clientId=" + + "cli" + + "&redirectUri=" + + urlencoding::encode(callback_url.as_str()) + .into_owned() + .as_str() + + "&state=" + + state.as_str() + + "&codeChallenge=" + + code_challenge.as_str() + + "&identifier=" + + sso_id, + ) + .map_err(|e| Error::FailedToOpenWebBrowser { err: e })?; + // TODO: probably it'd be better to display the URL in the console if the automatic + // open operation fails, instead of failing the whole process? E.g. docker container + // case + + let sso_code = callback_server.await?; + + Ok((sso_code, sso_code_verifier, callback_url)) + } + + pub async fn sync( + &self, + access_token: &str, + ) -> Result<( + String, + String, + HashMap, + Vec>, + )> { + let res = ClientRequest::Sync(access_token).req(self).await?; + let status = res.status(); + if !status.is_success() { + if let Ok(body) = res.text().await { + log::warn!("sync request failed with {status}: {body}"); + } + return Err(match status { + reqwest::StatusCode::UNAUTHORIZED => Error::RequestUnauthorized, + s => Error::RequestFailed { status: s.as_u16() }, + }); + } + + let sync_res: SyncRes = res.json_with_path().await?; + + let ciphers: Vec> = sync_res + .ciphers + .into_iter() + .filter_map(|cipher| match cipher.into_entry(&sync_res.folders) { + Ok(e) => Some(Ok(e)), + Err(Error::DeletedEntry) => None, // If deleted entry, simply skip it + Err(e) => Some(Err(e)), + }) + .collect::>>()?; + + let org_keys = sync_res + .profile + .organizations + .iter() + .map(|org| (org.id.clone(), org.key.clone())) + .collect(); + + Ok(( + sync_res.profile.key, + sync_res.profile.private_key, + org_keys, + ciphers, + )) + } + + pub async fn add( + &self, + access_token: &str, + name: &str, + data: &EntryData, + notes: Option<&str>, + folder_id: Option<&str>, + ) -> Result<()> { + let req = CiphersPostReq { + ty: entry_data_type(data), + folder_id: folder_id.map(|f| f.to_string()), + name: name.to_string(), + notes: notes.map(|n| n.to_string()), + data: data.clone().into(), + }; + + ClientRequest::Add(access_token, req) + .req(self) + .await? + .error_for_status()?; + + Ok(()) + } + + pub async fn edit(&self, access_token: &str, entry: &Entry) -> Result<()> { + let req: CiphersPutReq = entry.clone().into(); + + ClientRequest::Edit(access_token, &entry.id, req) + .req(self) + .await? + .error_for_status()?; + + Ok(()) + } + + pub async fn remove(&self, access_token: &str, id: &str) -> Result<()> { + ClientRequest::Remove(access_token, id) + .req(self) + .await? + .error_for_status()?; + + Ok(()) + } + + pub async fn folders(&self, access_token: &str) -> Result> { + let res = ClientRequest::Folders(access_token) + .req(self) + .await? + .error_for_status()?; + + let folders_res: FoldersRes = res.json_with_path().await?; + + Ok(folders_res + .data + .iter() + .map(|folder| (folder.id.clone(), folder.name.clone())) + .collect()) + } + + pub async fn create_folder(&self, access_token: &str, name: &str) -> Result { + let res = ClientRequest::CreateFolder(access_token, name) + .req(self) + .await? + .error_for_status()?; + + let folders_res: FoldersResData = res.json_with_path().await?; + + Ok(folders_res.id) + } + + pub async fn exchange_refresh_token( + &self, + refresh_token: &str, + ) -> Result<(String, Option)> { + let res = ClientRequest::ExchangeRefreshToken(refresh_token) + .req(self) + .await?; + let res = Self::check_connect_token_res(res).await?; + let connect_res: ConnectRefreshTokenRes = res.json_with_path().await?; + Ok((connect_res.access_token, connect_res.refresh_token)) + } + + pub async fn exchange_refresh_token_async( + &self, + refresh_token: &str, + ) -> Result<(String, Option)> { + let res = ClientRequest::ExchangeRefreshToken(refresh_token) + .req(self) + .await?; + let res = Self::check_connect_token_res(res).await?; + let connect_res: ConnectRefreshTokenRes = res.json_with_path().await?; + Ok((connect_res.access_token, connect_res.refresh_token)) + } + + pub(super) fn api_url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } + + pub(super) fn identity_url(&self, path: &str) -> String { + format!("{}{}", self.identity_url, path) + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 00000000..0ab27579 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,1050 @@ +// serde_repr generates some as conversions that we can't seem to silence from +// here, unfortunately +#![allow(clippy::as_conversions)] + +use std::{fmt::Display, str::FromStr}; + +use crate::{ + db::{Encrypted, Entry, EntryData}, + prelude::*, +}; + +use serde::{Deserialize, Serialize}; + +pub mod client; + +#[derive( + serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Debug, Copy, Clone, PartialEq, Eq, +)] +#[repr(u8)] +pub enum UriMatchType { + Domain = 0, + Host = 1, + StartsWith = 2, + Exact = 3, + RegularExpression = 4, + Never = 5, +} + +impl Display for UriMatchType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + #[allow(clippy::enum_glob_use)] + use UriMatchType::*; + let s = match self { + Domain => "domain", + Host => "host", + StartsWith => "starts_with", + Exact => "exact", + RegularExpression => "regular_expression", + Never => "never", + }; + write!(f, "{s}") + } +} + +struct IntegerStringVisitor(std::marker::PhantomData); + +impl serde::de::Visitor<'_> for IntegerStringVisitor +where + T: TryFrom + FromStr, + >::Error: Display, + ::Err: Display, +{ + type Value = T; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("integer or string") + } + + fn visit_u64(self, v: u64) -> std::result::Result { + T::try_from(v).map_err(serde::de::Error::custom) + } + + fn visit_str(self, v: &str) -> std::result::Result { + v.parse().map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum TwoFactorProviderType { + Authenticator = 0, + Email = 1, + Duo = 2, + Yubikey = 3, + U2f = 4, + Remember = 5, + OrganizationDuo = 6, + WebAuthn = 7, +} + +impl TwoFactorProviderType { + pub fn message(&self) -> &str { + match *self { + Self::Authenticator => { + "Enter the 6 digit verification code from your authenticator app." + } + Self::Yubikey => "Insert your Yubikey and push the button.", + Self::Email => "Enter the PIN you received via email.", + _ => "Enter the code.", + } + } + + pub fn header(&self) -> &str { + match *self { + Self::Authenticator => "Authenticator App", + Self::Yubikey => "Yubikey", + Self::Email => "Email Code", + _ => "Two Factor Authentication", + } + } + + pub fn grab(&self) -> bool { + !matches!(self, Self::Email) + } +} + +impl<'de> Deserialize<'de> for TwoFactorProviderType { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(IntegerStringVisitor(std::marker::PhantomData)) + } +} + +impl TryFrom for TwoFactorProviderType { + type Error = Error; + + fn try_from(ty: u64) -> Result { + match ty { + 0 => Ok(Self::Authenticator), + 1 => Ok(Self::Email), + 2 => Ok(Self::Duo), + 3 => Ok(Self::Yubikey), + 4 => Ok(Self::U2f), + 5 => Ok(Self::Remember), + 6 => Ok(Self::OrganizationDuo), + 7 => Ok(Self::WebAuthn), + _ => Err(Error::InvalidTwoFactorProvider { + ty: format!("{ty}"), + }), + } + } +} + +impl FromStr for TwoFactorProviderType { + type Err = Error; + + fn from_str(ty: &str) -> Result { + match ty { + "0" => Ok(Self::Authenticator), + "1" => Ok(Self::Email), + "2" => Ok(Self::Duo), + "3" => Ok(Self::Yubikey), + "4" => Ok(Self::U2f), + "5" => Ok(Self::Remember), + "6" => Ok(Self::OrganizationDuo), + "7" => Ok(Self::WebAuthn), + _ => Err(Error::InvalidTwoFactorProvider { ty: ty.to_string() }), + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum KdfType { + Pbkdf2 = 0, + Argon2id = 1, +} + +impl<'de> Deserialize<'de> for KdfType { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(IntegerStringVisitor(std::marker::PhantomData)) + } +} + +impl TryFrom for KdfType { + type Error = Error; + + fn try_from(ty: u64) -> Result { + match ty { + 0 => Ok(Self::Pbkdf2), + 1 => Ok(Self::Argon2id), + _ => Err(Error::InvalidKdfType { + ty: format!("{ty}"), + }), + } + } +} + +impl FromStr for KdfType { + type Err = Error; + + fn from_str(ty: &str) -> Result { + match ty { + "0" => Ok(Self::Pbkdf2), + "1" => Ok(Self::Argon2id), + _ => Err(Error::InvalidKdfType { ty: ty.to_string() }), + } + } +} + +impl Serialize for KdfType { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + let s = match self { + Self::Pbkdf2 => "0", + Self::Argon2id => "1", + }; + serializer.serialize_str(s) + } +} + +#[derive( + serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Debug, Copy, Clone, PartialEq, Eq, +)] +#[repr(u8)] +pub enum CipherRepromptType { + None = 0, + Password = 1, +} + +#[derive(Deserialize, Debug)] +struct PreloginRes { + #[serde(rename = "Kdf", alias = "kdf")] + kdf: KdfType, + #[serde(rename = "KdfIterations", alias = "kdfIterations")] + kdf_iterations: u32, + #[serde(rename = "KdfMemory", alias = "kdfMemory")] + kdf_memory: Option, + #[serde(rename = "KdfParallelism", alias = "kdfParallelism")] + kdf_parallelism: Option, +} + +#[derive(Serialize, Debug)] +#[serde(untagged)] +enum ConnectTokenAuth<'a> { + Password { + username: &'a str, + password: &'a str, + }, + AuthCode { + code: &'a str, + code_verifier: &'a str, + redirect_uri: &'a str, + }, + ClientCredentials { + username: &'a str, + client_secret: &'a str, + }, +} + +#[derive(Serialize, Debug)] +struct ConnectTokenReq<'a> { + grant_type: &'a str, + scope: &'a str, + client_id: &'a str, + #[serde(rename = "deviceType")] + device_type: u32, + #[serde(rename = "deviceIdentifier")] + device_identifier: &'a str, + #[serde(rename = "deviceName")] + device_name: &'a str, + #[serde(rename = "devicePushToken")] + device_push_token: &'a str, + #[serde(rename = "twoFactorToken")] + two_factor_token: Option<&'a str>, + #[serde(rename = "twoFactorProvider")] + two_factor_provider: Option, + #[serde(flatten)] + auth: ConnectTokenAuth<'a>, +} + +#[derive(Deserialize, Debug)] +struct ConnectTokenRes { + access_token: String, + refresh_token: String, + #[serde(rename = "Key", alias = "key")] + key: String, +} + +#[derive(Deserialize, Debug)] +struct ConnectErrorRes { + error: String, + error_description: Option, + #[serde(rename = "ErrorModel", alias = "errorModel")] + error_model: Option, + #[serde(rename = "TwoFactorProviders", alias = "twoFactorProviders")] + two_factor_providers: Option>, + #[serde(rename = "SsoEmail2faSessionToken", alias = "ssoEmail2faSessionToken")] + sso_email_2fa_session_token: Option, +} + +impl TryFrom for Error { + type Error = ConnectErrorRes; + + fn try_from(value: ConnectErrorRes) -> std::result::Result { + let error_desc = value.error_description.as_deref(); + match value.error.as_str() { + "invalid_grant" => match error_desc { + Some("invalid_username_or_password") => { + if let Some(model) = value.error_model { + return Ok(Error::IncorrectPassword { + message: model.message, + }); + } + } + Some("Two factor required.") => { + if let Some(providers) = value.two_factor_providers { + return Ok(Error::TwoFactorRequired { + providers, + sso_email_2fa_session_token: value.sso_email_2fa_session_token, + }); + } + } + Some("Captcha required.") => { + return Ok(Error::RegistrationRequired); + } + _ => {} + }, + "invalid_client" => { + return Ok(Error::IncorrectApiKey); + } + "" if error_desc.is_none() || error_desc == Some("") => { + // bitwarden_rs returns an empty error and error_description for + // this case, for some reason + if let Some(model) = value.error_model.as_ref() { + let message = model.message.clone(); + match message.as_str() { + "Username or password is incorrect. Try again" + | "TOTP code is not a number" => { + return Ok(Error::IncorrectPassword { message }); + } + s => { + if s.starts_with("Invalid TOTP code! Server time: ") { + return Ok(Error::IncorrectPassword { message }); + } + } + } + } + } + _ => {} + } + + Err(value) + } +} + +#[derive(Deserialize, Debug)] +struct ConnectErrorResErrorModel { + #[serde(rename = "Message", alias = "message")] + message: String, +} + +#[derive(Deserialize, Debug)] +struct ConnectRefreshTokenRes { + access_token: String, + refresh_token: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CipherLoginUri { + #[serde(rename = "Uri", alias = "uri")] + uri: Option, + #[serde(rename = "Match", alias = "match")] + match_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CipherLogin { + #[serde(rename = "Username", alias = "username")] + username: Option, + #[serde(rename = "Password", alias = "password")] + password: Option, + #[serde(rename = "Totp", alias = "totp")] + totp: Option, + #[serde(rename = "Uris", alias = "uris")] + uris: Option>, +} + +impl From for EntryData { + fn from(value: CipherLogin) -> Self { + Self::Login { + username: value.username, + password: value.password, + totp: value.totp, + uris: value.uris.map_or_else(Vec::new, |uris| { + uris.into_iter() + .filter_map(|uri| { + uri.uri.map(|s| crate::db::Uri { + uri: s, + match_type: uri.match_type, + }) + }) + .collect() + }), + } + } +} + +impl TryFrom for CipherLogin { + type Error = (); + + fn try_from(value: EntryData) -> std::result::Result { + let EntryData::Login { + username, + password, + totp, + uris, + } = value + else { + return Err(()); + }; + + Ok(CipherLogin { + username, + password, + totp, + uris: if uris.is_empty() { + None + } else { + Some( + uris.iter() + .map(|s| CipherLoginUri { + uri: Some(s.uri.clone()), + match_type: s.match_type, + }) + .collect(), + ) + }, + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CipherCard { + #[serde(rename = "CardholderName", alias = "cardholderName")] + cardholder_name: Option, + #[serde(rename = "Number", alias = "number")] + number: Option, + #[serde(rename = "Brand", alias = "brand")] + brand: Option, + #[serde(rename = "ExpMonth", alias = "expMonth")] + exp_month: Option, + #[serde(rename = "ExpYear", alias = "expYear")] + exp_year: Option, + #[serde(rename = "Code", alias = "code")] + code: Option, +} + +impl From for EntryData { + fn from(value: CipherCard) -> Self { + Self::Card { + cardholder_name: value.cardholder_name, + number: value.number, + brand: value.brand, + exp_month: value.exp_month, + exp_year: value.exp_year, + code: value.code, + } + } +} + +impl TryFrom for CipherCard { + type Error = (); + + fn try_from(value: EntryData) -> std::result::Result { + let EntryData::Card { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + } = value + else { + return Err(()); + }; + + Ok(Self { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CipherIdentity { + #[serde(rename = "Title", alias = "title")] + title: Option, + #[serde(rename = "FirstName", alias = "firstName")] + first_name: Option, + #[serde(rename = "MiddleName", alias = "middleName")] + middle_name: Option, + #[serde(rename = "LastName", alias = "lastName")] + last_name: Option, + #[serde(rename = "Address1", alias = "address1")] + address1: Option, + #[serde(rename = "Address2", alias = "address2")] + address2: Option, + #[serde(rename = "Address3", alias = "address3")] + address3: Option, + #[serde(rename = "City", alias = "city")] + city: Option, + #[serde(rename = "State", alias = "state")] + state: Option, + #[serde(rename = "PostalCode", alias = "postalCode")] + postal_code: Option, + #[serde(rename = "Country", alias = "country")] + country: Option, + #[serde(rename = "Phone", alias = "phone")] + phone: Option, + #[serde(rename = "Email", alias = "email")] + email: Option, + #[serde(rename = "SSN", alias = "ssn")] + ssn: Option, + #[serde(rename = "LicenseNumber", alias = "licenseNumber")] + license_number: Option, + #[serde(rename = "PassportNumber", alias = "passportNumber")] + passport_number: Option, + #[serde(rename = "Username", alias = "username")] + username: Option, +} + +impl From for EntryData { + fn from(value: CipherIdentity) -> Self { + Self::Identity { + title: value.title, + first_name: value.first_name, + middle_name: value.middle_name, + last_name: value.last_name, + address1: value.address1, + address2: value.address2, + address3: value.address3, + city: value.city, + state: value.state, + postal_code: value.postal_code, + country: value.country, + phone: value.phone, + email: value.email, + ssn: value.ssn, + license_number: value.license_number, + passport_number: value.passport_number, + username: value.username, + } + } +} + +impl TryFrom for CipherIdentity { + type Error = (); + + fn try_from(value: EntryData) -> std::result::Result { + let EntryData::Identity { + title, + first_name, + middle_name, + last_name, + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + } = value + else { + return Err(()); + }; + + Ok(Self { + title, + first_name, + middle_name, + last_name, + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CipherSshKey { + #[serde(rename = "PrivateKey", alias = "privateKey")] + private_key: Option, + #[serde(rename = "PublicKey", alias = "publicKey")] + public_key: Option, + #[serde(rename = "Fingerprint", alias = "keyFingerprint")] + fingerprint: Option, +} + +impl From for EntryData { + fn from(value: CipherSshKey) -> Self { + Self::SshKey { + private_key: value.private_key, + public_key: value.public_key, + fingerprint: value.fingerprint, + } + } +} + +impl TryFrom for CipherSshKey { + type Error = (); + + fn try_from(value: EntryData) -> std::result::Result { + let EntryData::SshKey { + private_key, + public_key, + fingerprint, + } = value + else { + return Err(()); + }; + + Ok(Self { + private_key, + public_key, + fingerprint, + }) + } +} + +// this is just a name and some notes, both of which are already on the cipher +// object +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CipherSecureNote {} + +impl From for EntryData { + fn from(_value: CipherSecureNote) -> Self { + Self::SecureNote + } +} + +impl TryFrom for CipherSecureNote { + type Error = (); + + fn try_from(value: EntryData) -> std::result::Result { + let EntryData::SecureNote = value else { + return Err(()); + }; + + Ok(Self {}) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CipherData { + #[serde(alias = "Login")] + login: Option, + #[serde(alias = "Card")] + card: Option, + #[serde(alias = "Identity")] + identity: Option, + #[serde(rename = "secureNote")] + secure_note: Option, + #[serde(alias = "SshKey", alias = "sshKey")] + ssh_key: Option, +} + +impl From for CipherData { + fn from(value: EntryData) -> Self { + match value { + EntryData::Login { .. } => Self { + login: Some(value.try_into().unwrap()), + card: None, + identity: None, + secure_note: None, + ssh_key: None, + }, + EntryData::Card { .. } => Self { + login: None, + card: Some(value.try_into().unwrap()), + identity: None, + secure_note: None, + ssh_key: None, + }, + EntryData::Identity { .. } => Self { + login: None, + card: None, + identity: Some(value.try_into().unwrap()), + secure_note: None, + ssh_key: None, + }, + EntryData::SecureNote => Self { + login: None, + card: None, + identity: None, + secure_note: Some(value.try_into().unwrap()), + ssh_key: None, + }, + EntryData::SshKey { .. } => Self { + login: None, + card: None, + identity: None, + secure_note: None, + ssh_key: Some(value.try_into().unwrap()), + }, + } + } +} + +impl TryFrom for EntryData { + type Error = Error; + fn try_from(value: CipherData) -> std::result::Result { + if let Some(login) = value.login { + Ok(login.into()) + } else if let Some(card) = value.card { + Ok(card.into()) + } else if let Some(identity) = value.identity { + Ok(identity.into()) + } else if let Some(secure_note) = value.secure_note { + Ok(secure_note.into()) + } else if let Some(ssh_key) = value.ssh_key { + Ok(ssh_key.into()) + } else { + Err(Error::EmptyCipherData) + } + } +} + +#[derive( + serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Debug, Clone, Copy, PartialEq, Eq, +)] +#[repr(u16)] +pub enum FieldType { + Text = 0, + Hidden = 1, + Boolean = 2, + Linked = 3, +} + +#[derive( + serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Debug, Clone, Copy, PartialEq, Eq, +)] +#[repr(u16)] +pub enum LinkedIdType { + LoginUsername = 100, + LoginPassword = 101, + CardCardholderName = 300, + CardExpMonth = 301, + CardExpYear = 302, + CardCode = 303, + CardBrand = 304, + CardNumber = 305, + IdentityTitle = 400, + IdentityMiddleName = 401, + IdentityAddress1 = 402, + IdentityAddress2 = 403, + IdentityAddress3 = 404, + IdentityCity = 405, + IdentityState = 406, + IdentityPostalCode = 407, + IdentityCountry = 408, + IdentityCompany = 409, + IdentityEmail = 410, + IdentityPhone = 411, + IdentitySsn = 412, + IdentityUsername = 413, + IdentityPassportNumber = 414, + IdentityLicenseNumber = 415, + IdentityFirstName = 416, + IdentityLastName = 417, + IdentityFullName = 418, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CipherDynamicField { + #[serde(rename = "Type", alias = "type")] + ty: Option, + #[serde(rename = "Name", alias = "name")] + name: Option, + #[serde(rename = "Value", alias = "value")] + value: Option, + #[serde(rename = "LinkedId", alias = "linkedId")] + linked_id: Option, +} + +impl From for crate::db::DynamicField { + fn from(value: CipherDynamicField) -> Self { + Self { + ty: value.ty, + name: value.name, + value: value.value, + linked_id: value.linked_id, + } + } +} + +impl From for CipherDynamicField { + fn from(value: crate::db::DynamicField) -> Self { + Self { + ty: value.ty, + name: value.name, + value: value.value, + linked_id: value.linked_id, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CipherHistoryEntry { + #[serde(rename = "LastUsedDate", alias = "lastUsedDate")] + last_used_date: String, + #[serde(rename = "Password", alias = "password")] + password: Option, +} + +impl From for CipherHistoryEntry { + fn from(value: crate::db::HistoryEntry) -> Self { + Self { + last_used_date: value.last_used_date, + password: Some(value.password), + } + } +} + +impl From for Option { + fn from(value: CipherHistoryEntry) -> Self { + let password = value.password?; + + Some(crate::db::HistoryEntry { + last_used_date: value.last_used_date, + password, + }) + } +} + +#[derive(Deserialize, Debug, Clone)] +struct SyncResCipher { + #[serde(alias = "Id")] + id: String, + #[serde(alias = "FolderId", alias = "folderId")] + folder_id: Option, + #[serde(alias = "OrganizationId", alias = "organizationId")] + organization_id: Option, + #[serde(alias = "Name")] + name: String, + #[serde(flatten)] + data: CipherData, + #[serde(alias = "Notes")] + notes: Option, + #[serde(alias = "PasswordHistory", alias = "passwordHistory")] + password_history: Option>, + #[serde(alias = "Fields")] + fields: Option>, + #[serde(alias = "DeletedDate", alias = "deletedDate")] + deleted_date: Option, + #[serde(alias = "Key")] + key: Option, + #[serde(alias = "Reprompt")] + reprompt: CipherRepromptType, +} + +// impl From> for Cipher { +// fn from(value: Entry) -> Self { +// Self { +// id: value.id, +// folder_id: value.folder_id, +// organization_id: value.org_id, +// name: value.name, +// data: value.data.into(), +// notes: value.notes, +// password_history: if value.history.is_empty() { +// None +// } else { +// Some(value.history.into_iter().map(|he| he.into()).collect()) +// }, +// fields: if value.fields.is_empty() { +// None +// } else { +// Some(value.fields.into_iter().map(|f| f.into()).collect()) +// }, +// deleted_date: None, +// key: value.key, +// reprompt: value.master_password_reprompt, +// } +// } +// } + +impl SyncResCipher { + fn into_entry(self, folders: &[SyncResFolder]) -> Result> { + if self.deleted_date.is_some() { + return Err(Error::DeletedEntry); + } + + let history: Vec = self + .password_history + .map_or(vec![], |e| e.into_iter().filter_map(Into::into).collect()); + + let (folder, folder_id) = self.folder_id.map_or((None, None), |folder_id| { + let mut folder_name = None; + for folder in folders { + if folder.id == folder_id { + folder_name = Some(folder.name.clone()); + } + } + (folder_name, Some(folder_id)) + }); + + let fields: Vec = self.fields.map_or_else(Vec::new, |fields| { + fields.into_iter().map(Into::into).collect() + }); + + Ok(crate::db::Entry:: { + id: self.id, + org_id: self.organization_id, + folder, + folder_id, + name: self.name, + data: self.data.try_into()?, + fields, + notes: self.notes, + history, + key: self.key, + master_password_reprompt: self.reprompt, + _state: std::marker::PhantomData, + }) + } +} + +#[derive(Deserialize, Debug)] +struct SyncResProfile { + #[serde(rename = "Key", alias = "key")] + key: String, + #[serde(rename = "PrivateKey", alias = "privateKey")] + private_key: String, + #[serde(rename = "Organizations", alias = "organizations")] + organizations: Vec, +} + +#[derive(Deserialize, Debug)] +struct SyncResProfileOrganization { + #[serde(rename = "Id", alias = "id")] + id: String, + #[serde(rename = "Key", alias = "key")] + key: String, +} + +#[derive(Deserialize, Debug, Clone)] +struct SyncResFolder { + #[serde(rename = "Id", alias = "id")] + id: String, + #[serde(rename = "Name", alias = "name")] + name: String, +} + +#[derive(Deserialize, Debug)] +struct SyncRes { + #[serde(rename = "Ciphers", alias = "ciphers")] + ciphers: Vec, + #[serde(rename = "Profile", alias = "profile")] + profile: SyncResProfile, + #[serde(rename = "Folders", alias = "folders")] + folders: Vec, +} + +fn entry_data_type(data: &EntryData) -> u32 { + match data { + EntryData::Login { .. } => 1, + EntryData::Card { .. } => 3, + EntryData::Identity { .. } => 4, + EntryData::SecureNote => 2, + EntryData::SshKey { .. } => unreachable!(), // TODO: Fix me + } +} + +fn _cipher_data_type(data: &CipherData) -> u32 { + if data.login.is_some() { + 1 + } else if data.card.is_some() { + 3 + } else if data.identity.is_some() { + 4 + } else if data.secure_note.is_some() { + 2 + } else { + unreachable!() + } +} + +#[derive(Serialize, Debug)] +struct CiphersPostReq { + #[serde(rename = "type")] + ty: u32, // XXX what are the valid types? + #[serde(rename = "folderId")] + folder_id: Option, + name: String, + notes: Option, + #[serde(flatten)] + data: CipherData, +} + +#[derive(Serialize, Debug)] +struct CiphersPutReq { + #[serde(rename = "type")] + ty: u32, // XXX what are the valid types? + #[serde(rename = "folderId")] + folder_id: Option, + #[serde(rename = "organizationId")] + organization_id: Option, + name: String, + notes: Option, + #[serde(flatten)] + data: CipherData, + fields: Vec, + #[serde(rename = "passwordHistory")] + password_history: Vec, +} + +impl From> for CiphersPutReq { + fn from(value: Entry) -> Self { + Self { + ty: entry_data_type(&value.data), + folder_id: value.folder_id, + organization_id: value.org_id, + name: value.name, + notes: value.notes, + data: value.data.into(), + fields: value.fields.into_iter().map(|f| f.into()).collect(), + password_history: value.history.into_iter().map(|he| he.into()).collect(), + } + } +} + +#[derive(Deserialize, Debug)] +struct FoldersResData { + #[serde(rename = "Id", alias = "id")] + id: String, + #[serde(rename = "Name", alias = "name")] + name: String, +} + +#[derive(Deserialize, Debug)] +struct FoldersRes { + #[serde(rename = "Data", alias = "data")] + data: Vec, +} diff --git a/src/base64.rs b/src/base64.rs index 86971bc8..1fe31159 100644 --- a/src/base64.rs +++ b/src/base64.rs @@ -8,8 +8,6 @@ pub fn encode_url_safe_no_pad>(input: T) -> String { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input) } -pub fn decode>( - input: T, -) -> Result, base64::DecodeError> { +pub fn decode>(input: T) -> Result, base64::DecodeError> { base64::engine::general_purpose::STANDARD.decode(input) } diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs deleted file mode 100644 index 9ddd2ad9..00000000 --- a/src/bin/rbw-agent/actions.rs +++ /dev/null @@ -1,952 +0,0 @@ -use anyhow::Context as _; -use sha2::Digest as _; - -pub async fn register( - sock: &mut crate::sock::Sock, - environment: &rbw::protocol::Environment, -) -> anyhow::Result<()> { - let db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new()); - - if db.needs_login() { - let url_str = config_base_url().await?; - let url = reqwest::Url::parse(&url_str) - .context("failed to parse base url")?; - let Some(host) = url.host_str() else { - return Err(anyhow::anyhow!( - "couldn't find host in rbw base url {url_str}" - )); - }; - - let email = config_email().await?; - - let mut err_msg = None; - for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop - // if we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) - } else { - None - }; - let client_id = rbw::pinentry::getpin( - &config_pinentry().await?, - "API key client__id", - &format!("Log in to {host}"), - err.as_deref(), - environment, - false, - ) - .await - .context("failed to read client_id from pinentry")?; - let client_secret = rbw::pinentry::getpin( - &config_pinentry().await?, - "API key client__secret", - &format!("Log in to {host}"), - err.as_deref(), - environment, - false, - ) - .await - .context("failed to read client_secret from pinentry")?; - let apikey = rbw::locked::ApiKey::new(client_id, client_secret); - match rbw::actions::register(&email, apikey.clone()).await { - Ok(()) => { - break; - } - Err(rbw::error::Error::IncorrectPassword { message }) => { - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to log in to bitwarden instance"); - } - err_msg = Some(message); - } - Err(e) => { - return Err(e) - .context("failed to log in to bitwarden instance") - } - } - } - } - - respond_ack(sock).await?; - - Ok(()) -} - -pub async fn login( - sock: &mut crate::sock::Sock, - state: std::sync::Arc>, - environment: &rbw::protocol::Environment, -) -> anyhow::Result<()> { - let db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new()); - - if db.needs_login() { - let url_str = config_base_url().await?; - let url = reqwest::Url::parse(&url_str) - .context("failed to parse base url")?; - let Some(host) = url.host_str() else { - return Err(anyhow::anyhow!( - "couldn't find host in rbw base url {url_str}" - )); - }; - - let email = config_email().await?; - - let mut err_msg = None; - 'attempts: for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop - // if we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) - } else { - None - }; - let password = rbw::pinentry::getpin( - &config_pinentry().await?, - "Master Password", - &format!("Log in to {host}"), - err.as_deref(), - environment, - true, - ) - .await - .context("failed to read password from pinentry")?; - match rbw::actions::login(&email, password.clone(), None, None) - .await - { - Ok(( - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - )) => { - login_success( - state.clone(), - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - password, - db, - email, - ) - .await?; - break 'attempts; - } - Err(rbw::error::Error::TwoFactorRequired { - providers, - sso_email_2fa_session_token, - }) => { - let supported_types = vec![ - rbw::api::TwoFactorProviderType::Authenticator, - rbw::api::TwoFactorProviderType::Yubikey, - rbw::api::TwoFactorProviderType::Email, - ]; - - for provider in supported_types { - if providers.contains(&provider) { - if provider - == rbw::api::TwoFactorProviderType::Email - { - if let Some(sso_email_2fa_session_token) = - sso_email_2fa_session_token - { - rbw::actions::send_two_factor_email( - &email, - &sso_email_2fa_session_token, - ) - .await?; - } - } - let ( - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - ) = two_factor( - environment, - &email, - password.clone(), - provider, - ) - .await?; - login_success( - state.clone(), - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - password, - db, - email, - ) - .await?; - break 'attempts; - } - } - return Err(anyhow::anyhow!( - "unsupported two factor methods: {providers:?}" - )); - } - Err(rbw::error::Error::IncorrectPassword { message }) => { - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to log in to bitwarden instance"); - } - err_msg = Some(message); - } - Err(e) => { - return Err(e) - .context("failed to log in to bitwarden instance") - } - } - } - } - - respond_ack(sock).await?; - - Ok(()) -} - -async fn two_factor( - environment: &rbw::protocol::Environment, - email: &str, - password: rbw::locked::Password, - provider: rbw::api::TwoFactorProviderType, -) -> anyhow::Result<( - String, - String, - rbw::api::KdfType, - u32, - Option, - Option, - String, -)> { - let mut err_msg = None; - for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop if - // we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) - } else { - None - }; - let code = rbw::pinentry::getpin( - &config_pinentry().await?, - provider.header(), - provider.message(), - err.as_deref(), - environment, - provider.grab(), - ) - .await - .context("failed to read code from pinentry")?; - let code = std::str::from_utf8(code.password()) - .context("code was not valid utf8")?; - match rbw::actions::login( - email, - password.clone(), - Some(code), - Some(provider), - ) - .await - { - Ok(( - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - )) => { - return Ok(( - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - )) - } - Err(rbw::error::Error::IncorrectPassword { message }) => { - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to log in to bitwarden instance"); - } - err_msg = Some(message); - } - // can get this if the user passes an empty string - Err(rbw::error::Error::TwoFactorRequired { .. }) => { - let message = "TOTP code is not a number".to_string(); - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to log in to bitwarden instance"); - } - err_msg = Some(message); - } - Err(e) => { - return Err(e) - .context("failed to log in to bitwarden instance") - } - } - } - - unreachable!() -} - -async fn login_success( - state: std::sync::Arc>, - access_token: String, - refresh_token: String, - kdf: rbw::api::KdfType, - iterations: u32, - memory: Option, - parallelism: Option, - protected_key: String, - password: rbw::locked::Password, - mut db: rbw::db::Db, - email: String, -) -> anyhow::Result<()> { - db.access_token = Some(access_token.clone()); - db.refresh_token = Some(refresh_token.clone()); - db.kdf = Some(kdf); - db.iterations = Some(iterations); - db.memory = memory; - db.parallelism = parallelism; - db.protected_key = Some(protected_key.clone()); - save_db(&db).await?; - - sync(None, state.clone()).await?; - let db = load_db().await?; - - let Some(protected_private_key) = db.protected_private_key else { - return Err(anyhow::anyhow!( - "failed to find protected private key in db" - )); - }; - - let res = rbw::actions::unlock( - &email, - &password, - kdf, - iterations, - memory, - parallelism, - &protected_key, - &protected_private_key, - &db.protected_org_keys, - ); - - match res { - Ok((keys, org_keys)) => { - let mut state = state.lock().await; - state.priv_key = Some(keys); - state.org_keys = Some(org_keys); - } - Err(e) => return Err(e).context("failed to unlock database"), - } - - Ok(()) -} - -async fn unlock_state( - state: std::sync::Arc>, - environment: &rbw::protocol::Environment, -) -> anyhow::Result<()> { - if state.lock().await.needs_unlock() { - let db = load_db().await?; - - let Some(kdf) = db.kdf else { - return Err(anyhow::anyhow!("failed to find kdf type in db")); - }; - - let Some(iterations) = db.iterations else { - return Err(anyhow::anyhow!( - "failed to find number of iterations in db" - )); - }; - - let memory = db.memory; - let parallelism = db.parallelism; - - let Some(protected_key) = db.protected_key else { - return Err(anyhow::anyhow!( - "failed to find protected key in db" - )); - }; - let Some(protected_private_key) = db.protected_private_key else { - return Err(anyhow::anyhow!( - "failed to find protected private key in db" - )); - }; - - let email = config_email().await?; - - let mut err_msg = None; - for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop - // if we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) - } else { - None - }; - let password = rbw::pinentry::getpin( - &config_pinentry().await?, - "Master Password", - &format!( - "Unlock the local database for '{}'", - rbw::dirs::profile() - ), - err.as_deref(), - environment, - true, - ) - .await - .context("failed to read password from pinentry")?; - match rbw::actions::unlock( - &email, - &password, - kdf, - iterations, - memory, - parallelism, - &protected_key, - &protected_private_key, - &db.protected_org_keys, - ) { - Ok((keys, org_keys)) => { - unlock_success(state, keys, org_keys).await?; - break; - } - Err(rbw::error::Error::IncorrectPassword { message }) => { - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to unlock database"); - } - err_msg = Some(message); - } - Err(e) => return Err(e).context("failed to unlock database"), - } - } - } - - Ok(()) -} - -pub async fn unlock( - sock: &mut crate::sock::Sock, - state: std::sync::Arc>, - environment: &rbw::protocol::Environment, -) -> anyhow::Result<()> { - unlock_state(state, environment).await?; - - respond_ack(sock).await?; - - Ok(()) -} - -async fn unlock_success( - state: std::sync::Arc>, - keys: rbw::locked::Keys, - org_keys: std::collections::HashMap, -) -> anyhow::Result<()> { - let mut state = state.lock().await; - state.priv_key = Some(keys); - state.org_keys = Some(org_keys); - Ok(()) -} - -pub async fn lock( - sock: &mut crate::sock::Sock, - state: std::sync::Arc>, -) -> anyhow::Result<()> { - state.lock().await.clear(); - - respond_ack(sock).await?; - - Ok(()) -} - -pub async fn check_lock( - sock: &mut crate::sock::Sock, - state: std::sync::Arc>, -) -> anyhow::Result<()> { - if state.lock().await.needs_unlock() { - return Err(anyhow::anyhow!("agent is locked")); - } - - respond_ack(sock).await?; - - Ok(()) -} - -pub async fn sync( - sock: Option<&mut crate::sock::Sock>, - state: std::sync::Arc>, -) -> anyhow::Result<()> { - let mut db = load_db().await?; - - let access_token = if let Some(access_token) = &db.access_token { - access_token.clone() - } else { - return Err(anyhow::anyhow!("failed to find access token in db")); - }; - let refresh_token = if let Some(refresh_token) = &db.refresh_token { - refresh_token.clone() - } else { - return Err(anyhow::anyhow!("failed to find refresh token in db")); - }; - let ( - access_token, - (protected_key, protected_private_key, protected_org_keys, entries), - ) = rbw::actions::sync(&access_token, &refresh_token) - .await - .context("failed to sync database from server")?; - state.lock().await.set_master_password_reprompt(&entries); - if let Some(access_token) = access_token { - db.access_token = Some(access_token); - } - db.protected_key = Some(protected_key); - db.protected_private_key = Some(protected_private_key); - db.protected_org_keys = protected_org_keys; - db.entries = entries; - save_db(&db).await?; - - if let Err(e) = subscribe_to_notifications(state.clone()).await { - eprintln!("failed to subscribe to notifications: {e}"); - } - - if let Some(sock) = sock { - respond_ack(sock).await?; - } - - Ok(()) -} - -async fn decrypt_cipher( - state: std::sync::Arc>, - environment: &rbw::protocol::Environment, - cipherstring: &str, - entry_key: Option<&str>, - org_id: Option<&str>, -) -> anyhow::Result { - let mut state = state.lock().await; - if !state.master_password_reprompt_initialized() { - let db = load_db().await?; - state.set_master_password_reprompt(&db.entries); - } - let Some(keys) = state.key(org_id) else { - return Err(anyhow::anyhow!( - "failed to find decryption keys in in-memory state" - )); - }; - let entry_key = if let Some(entry_key) = entry_key { - let key_cipherstring = - rbw::cipherstring::CipherString::new(entry_key) - .context("failed to parse individual item encryption key")?; - Some(rbw::locked::Keys::new( - key_cipherstring.decrypt_locked_symmetric(keys).context( - "failed to decrypt individual item encryption key", - )?, - )) - } else { - None - }; - - let mut sha256 = sha2::Sha256::new(); - sha256.update(cipherstring); - let master_password_reprompt: [u8; 32] = sha256.finalize().into(); - if state - .master_password_reprompt - .contains(&master_password_reprompt) - { - let db = load_db().await?; - - let Some(kdf) = db.kdf else { - return Err(anyhow::anyhow!("failed to find kdf type in db")); - }; - - let Some(iterations) = db.iterations else { - return Err(anyhow::anyhow!( - "failed to find number of iterations in db" - )); - }; - - let memory = db.memory; - let parallelism = db.parallelism; - - let Some(protected_key) = db.protected_key else { - return Err(anyhow::anyhow!( - "failed to find protected key in db" - )); - }; - let Some(protected_private_key) = db.protected_private_key else { - return Err(anyhow::anyhow!( - "failed to find protected private key in db" - )); - }; - - let email = config_email().await?; - - let mut err_msg = None; - for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop - // if we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) - } else { - None - }; - let password = rbw::pinentry::getpin( - &config_pinentry().await?, - "Master Password", - "Accessing this entry requires the master password", - err.as_deref(), - environment, - true, - ) - .await - .context("failed to read password from pinentry")?; - match rbw::actions::unlock( - &email, - &password, - kdf, - iterations, - memory, - parallelism, - &protected_key, - &protected_private_key, - &db.protected_org_keys, - ) { - Ok(_) => { - break; - } - Err(rbw::error::Error::IncorrectPassword { message }) => { - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to unlock database"); - } - err_msg = Some(message); - } - Err(e) => return Err(e).context("failed to unlock database"), - } - } - } - - let cipherstring = rbw::cipherstring::CipherString::new(cipherstring) - .context("failed to parse encrypted secret")?; - let plaintext = String::from_utf8( - cipherstring - .decrypt_symmetric(keys, entry_key.as_ref()) - .context("failed to decrypt encrypted secret")?, - ) - .context("failed to parse decrypted secret")?; - - Ok(plaintext) -} - -pub async fn decrypt( - sock: &mut crate::sock::Sock, - state: std::sync::Arc>, - environment: &rbw::protocol::Environment, - cipherstring: &str, - entry_key: Option<&str>, - org_id: Option<&str>, -) -> anyhow::Result<()> { - let plaintext = - decrypt_cipher(state, environment, cipherstring, entry_key, org_id) - .await?; - respond_decrypt(sock, plaintext).await?; - - Ok(()) -} - -pub async fn encrypt( - sock: &mut crate::sock::Sock, - state: std::sync::Arc>, - plaintext: &str, - org_id: Option<&str>, -) -> anyhow::Result<()> { - let state = state.lock().await; - let Some(keys) = state.key(org_id) else { - return Err(anyhow::anyhow!( - "failed to find encryption keys in in-memory state" - )); - }; - let cipherstring = rbw::cipherstring::CipherString::encrypt_symmetric( - keys, - plaintext.as_bytes(), - ) - .context("failed to encrypt plaintext secret")?; - - respond_encrypt(sock, cipherstring.to_string()).await?; - - Ok(()) -} - -#[cfg(feature = "clipboard")] -pub async fn clipboard_store( - sock: &mut crate::sock::Sock, - state: std::sync::Arc>, - text: &str, -) -> anyhow::Result<()> { - let mut state = state.lock().await; - if let Some(clipboard) = &mut state.clipboard { - clipboard.set_text(text).map_err(|e| { - anyhow::anyhow!("couldn't store value to clipboard: {e}") - })?; - } - - respond_ack(sock).await?; - - Ok(()) -} - -#[cfg(not(feature = "clipboard"))] -pub async fn clipboard_store( - sock: &mut crate::sock::Sock, - _state: std::sync::Arc>, - _text: &str, -) -> anyhow::Result<()> { - sock.send(&rbw::protocol::Response::Error { - error: "clipboard not supported".to_string(), - }) - .await?; - - Ok(()) -} - -pub async fn version(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { - sock.send(&rbw::protocol::Response::Version { - version: rbw::protocol::VERSION, - }) - .await?; - - Ok(()) -} - -async fn respond_ack(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { - sock.send(&rbw::protocol::Response::Ack).await?; - - Ok(()) -} - -async fn respond_decrypt( - sock: &mut crate::sock::Sock, - plaintext: String, -) -> anyhow::Result<()> { - sock.send(&rbw::protocol::Response::Decrypt { plaintext }) - .await?; - - Ok(()) -} - -async fn respond_encrypt( - sock: &mut crate::sock::Sock, - cipherstring: String, -) -> anyhow::Result<()> { - sock.send(&rbw::protocol::Response::Encrypt { cipherstring }) - .await?; - - Ok(()) -} - -async fn config_email() -> anyhow::Result { - let config = rbw::config::Config::load_async().await?; - config.email.map_or_else( - || Err(anyhow::anyhow!("failed to find email address in config")), - Ok, - ) -} - -async fn load_db() -> anyhow::Result { - let config = rbw::config::Config::load_async().await?; - if let Some(email) = &config.email { - rbw::db::Db::load_async(&config.server_name(), email) - .await - .map_err(anyhow::Error::new) - } else { - Err(anyhow::anyhow!("failed to find email address in config")) - } -} - -async fn save_db(db: &rbw::db::Db) -> anyhow::Result<()> { - let config = rbw::config::Config::load_async().await?; - if let Some(email) = &config.email { - db.save_async(&config.server_name(), email) - .await - .map_err(anyhow::Error::new) - } else { - Err(anyhow::anyhow!("failed to find email address in config")) - } -} - -async fn config_base_url() -> anyhow::Result { - let config = rbw::config::Config::load_async().await?; - Ok(config.base_url()) -} - -async fn config_pinentry() -> anyhow::Result { - let config = rbw::config::Config::load_async().await?; - Ok(config.pinentry) -} - -pub async fn subscribe_to_notifications( - state: std::sync::Arc>, -) -> anyhow::Result<()> { - if state.lock().await.notifications_handler.is_connected() { - return Ok(()); - } - - let config = rbw::config::Config::load_async() - .await - .context("Config is missing")?; - let email = config.email.clone().context("Config is missing email")?; - let db = rbw::db::Db::load_async(config.server_name().as_str(), &email) - .await?; - let access_token = - db.access_token.context("Error getting access token")?; - - let websocket_url = format!( - "{}/hub?access_token={}", - config.notifications_url(), - access_token - ) - .replace("https://", "wss://"); - - let mut state = state.lock().await; - state - .notifications_handler - .connect(websocket_url) - .await - .err() - .map_or_else(|| Ok(()), |err| Err(anyhow::anyhow!(err.to_string()))) -} - -pub async fn get_ssh_public_keys( - state: std::sync::Arc>, -) -> anyhow::Result> { - let environment = { - let state = state.lock().await; - state.set_timeout(); - state.last_environment().clone() - }; - unlock_state(state.clone(), &environment).await?; - - let db = load_db().await?; - let mut pubkeys = Vec::new(); - - for entry in db.entries { - if let rbw::db::EntryData::SshKey { - public_key: Some(encrypted), - .. - } = &entry.data - { - let plaintext = decrypt_cipher( - state.clone(), - &environment, - encrypted, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .await?; - - pubkeys.push(plaintext); - } - } - - Ok(pubkeys) -} - -pub async fn find_ssh_private_key( - state: std::sync::Arc>, - request_public_key: ssh_agent_lib::ssh_key::PublicKey, -) -> anyhow::Result { - let environment = { - let state = state.lock().await; - state.set_timeout(); - state.last_environment().clone() - }; - unlock_state(state.clone(), &environment).await?; - - let request_bytes = request_public_key.to_bytes(); - - let db = load_db().await?; - - for entry in db.entries { - if let rbw::db::EntryData::SshKey { - private_key, - public_key, - .. - } = &entry.data - { - let Some(public_key_enc) = public_key else { - continue; - }; - let public_key_plaintext = decrypt_cipher( - state.clone(), - &environment, - public_key_enc, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .await?; - let public_key_bytes = - ssh_agent_lib::ssh_key::PublicKey::from_openssh( - &public_key_plaintext, - ) - .map_err(anyhow::Error::new)? - .to_bytes(); - - if public_key_bytes == request_bytes { - let private_key_enc = - private_key.as_ref().ok_or_else(|| { - anyhow::anyhow!("Matching entry has no private key") - })?; - - let private_key_plaintext = decrypt_cipher( - state.clone(), - &environment, - private_key_enc, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .await?; - - return ssh_agent_lib::ssh_key::PrivateKey::from_openssh( - private_key_plaintext, - ) - .map_err(anyhow::Error::new); - } - } - } - - Err(anyhow::anyhow!("No matching private key found")) -} diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs deleted file mode 100644 index 1691ed51..00000000 --- a/src/bin/rbw-agent/agent.rs +++ /dev/null @@ -1,194 +0,0 @@ -use anyhow::Context as _; -use futures_util::StreamExt as _; - -pub struct Agent { - timer_r: tokio::sync::mpsc::UnboundedReceiver<()>, - sync_timer_r: tokio::sync::mpsc::UnboundedReceiver<()>, - state: std::sync::Arc>, -} - -impl Agent { - pub fn new( - timer_r: tokio::sync::mpsc::UnboundedReceiver<()>, - sync_timer_r: tokio::sync::mpsc::UnboundedReceiver<()>, - state: std::sync::Arc>, - ) -> Self { - Self { - timer_r, - sync_timer_r, - state, - } - } - - pub async fn run( - self, - listener: tokio::net::UnixListener, - ) -> anyhow::Result<()> { - pub enum Event { - Request(std::io::Result), - Timeout(()), - Sync(()), - } - - let notifications = self - .state - .lock() - .await - .notifications_handler - .get_channel() - .await; - let notifications = - tokio_stream::wrappers::UnboundedReceiverStream::new( - notifications, - ) - .map(|message| match message { - crate::notifications::Message::Logout => Event::Timeout(()), - crate::notifications::Message::Sync => Event::Sync(()), - }) - .boxed(); - - let mut stream = futures_util::stream::select_all([ - tokio_stream::wrappers::UnixListenerStream::new(listener) - .map(Event::Request) - .boxed(), - tokio_stream::wrappers::UnboundedReceiverStream::new( - self.timer_r, - ) - .map(Event::Timeout) - .boxed(), - tokio_stream::wrappers::UnboundedReceiverStream::new( - self.sync_timer_r, - ) - .map(Event::Sync) - .boxed(), - notifications, - ]); - while let Some(event) = stream.next().await { - match event { - Event::Request(res) => { - let mut sock = crate::sock::Sock::new( - res.context("failed to accept incoming connection")?, - ); - let state = self.state.clone(); - tokio::spawn(async move { - let res = - handle_request(&mut sock, state.clone()).await; - if let Err(e) = res { - // unwrap is the only option here - sock.send(&rbw::protocol::Response::Error { - error: format!("{e:#}"), - }) - .await - .unwrap(); - } - }); - } - Event::Timeout(()) => { - self.state.lock().await.clear(); - } - Event::Sync(()) => { - let state = self.state.clone(); - tokio::spawn(async move { - // this could fail if we aren't logged in, but we - // don't care about that - if let Err(e) = - crate::actions::sync(None, state.clone()).await - { - eprintln!("failed to sync: {e:#}"); - } - }); - self.state.lock().await.set_sync_timeout(); - } - } - } - Ok(()) - } -} - -async fn handle_request( - sock: &mut crate::sock::Sock, - state: std::sync::Arc>, -) -> anyhow::Result<()> { - let req = sock.recv().await?; - let req = match req { - Ok(msg) => msg, - Err(error) => { - sock.send(&rbw::protocol::Response::Error { error }).await?; - return Ok(()); - } - }; - let (action, environment) = req.into_parts(); - let set_timeout = match &action { - rbw::protocol::Action::Register => { - crate::actions::register(sock, &environment).await?; - true - } - rbw::protocol::Action::Login => { - crate::actions::login(sock, state.clone(), &environment).await?; - true - } - rbw::protocol::Action::Unlock => { - crate::actions::unlock(sock, state.clone(), &environment).await?; - true - } - rbw::protocol::Action::CheckLock => { - crate::actions::check_lock(sock, state.clone()).await?; - false - } - rbw::protocol::Action::Lock => { - crate::actions::lock(sock, state.clone()).await?; - false - } - rbw::protocol::Action::Sync => { - crate::actions::sync(Some(sock), state.clone()).await?; - false - } - rbw::protocol::Action::Decrypt { - cipherstring, - entry_key, - org_id, - } => { - let cipherstring = cipherstring.clone(); - let entry_key = entry_key.clone(); - let org_id = org_id.clone(); - crate::actions::decrypt( - sock, - state.clone(), - &environment, - &cipherstring, - entry_key.as_deref(), - org_id.as_deref(), - ) - .await?; - true - } - rbw::protocol::Action::Encrypt { plaintext, org_id } => { - crate::actions::encrypt( - sock, - state.clone(), - plaintext, - org_id.as_deref(), - ) - .await?; - true - } - rbw::protocol::Action::ClipboardStore { text } => { - crate::actions::clipboard_store(sock, state.clone(), text) - .await?; - true - } - rbw::protocol::Action::Quit => std::process::exit(0), - rbw::protocol::Action::Version => { - crate::actions::version(sock).await?; - false - } - }; - - let mut state = state.lock().await; - state.set_last_environment(environment); - if set_timeout { - state.set_timeout(); - } - - Ok(()) -} diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs new file mode 100644 index 00000000..87f9a2a7 --- /dev/null +++ b/src/bin/rbw-agent/agent/actions.rs @@ -0,0 +1,724 @@ +use std::future::Future; + +use anyhow::Context as _; +use rbw::{ + actions::SessionParameters, + db::{Db, EntryData}, + error::{Error, Result}, +}; +use sha2::Digest as _; + +use crate::agent::Agent; + +async fn with_retry(c: C) -> anyhow::Result +where + C: Fn(Option) -> Fut, + Fut: Future>, +{ + let mut err_msg = None; + + for i in 1..=3 { + let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); + + match c(err).await { + Ok(r) => { + return Ok(r); + } + Err(e) => { + if let Some(e) = e.downcast_ref::() { + match e { + rbw::error::Error::IncorrectPassword { message } if i < 3 => { + err_msg = Some(message.clone()); + continue; + } + // TODO: Move this back where it was if possible + rbw::error::Error::TwoFactorRequired { .. } if i < 3 => { + err_msg = Some("TOTP code is not a number".to_string()); + continue; + } + _ => {} + } + } + + return Err(e); + } + } + } + + unreachable!() +} + +impl Agent { + async fn getpin( + &self, + desc: &str, + prompt: &str, + err: &Option, + environment: &rbw::protocol::Environment, + grab: bool, + ) -> anyhow::Result { + Ok(rbw::pinentry::getpin( + self.config_pinentry(), + prompt, + desc, + err.as_deref(), + environment, + grab, + ) + .await?) + } + + async fn get_client_id_secret( + &self, + host: &str, + err: &Option, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result<(rbw::locked::Password, rbw::locked::Password)> { + let id = self + .getpin( + "API key client__id", + &format!("Log in to {host}"), + err, + environment, + false, + ) + .await + .context("failed to read client_id from pinentry")?; + + let secret = self + .getpin( + "API key client__secret", + &format!("Log in to {host}"), + err, + environment, + false, + ) + .await + .context("failed to read client_secret from pinentry")?; + + Ok((id, secret)) + } + + fn get_host(&self) -> anyhow::Result { + let url_str = self.base_url(); + let url = reqwest::Url::parse(&url_str).context("failed to parse base url")?; + let Some(host) = url.host_str() else { + return Err(anyhow::anyhow!( + "couldn't find host in rbw base url {url_str}" + )); + }; + + Ok(host.to_string()) + } + + pub async fn register( + &self, + sock: &mut crate::sock::Sock, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result<()> { + if !self.inner.db.read().await.needs_login() { + return respond_ack(sock).await; + } + + let host = &self.get_host()?; + + let email = &self.email()?.to_string(); + + with_retry(|e| async move { + let (client_id, client_secret) = + self.get_client_id_secret(host, &e, environment).await?; + + let apikey = rbw::locked::ApiKey::new(client_id, client_secret); + + Ok(rbw::actions::register(email, apikey).await?) + }) + .await + .context("failed to log in to bitwarden instance")?; + + respond_ack(sock).await?; + + Ok(()) + } + + async fn get_code( + &self, + provider: rbw::api::TwoFactorProviderType, + err: &Option, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result { + self.getpin( + provider.header(), + provider.message(), + err, + environment, + provider.grab(), + ) + .await + .context("failed to read code from pinentry") + } + + async fn two_factor( + &self, + environment: &rbw::protocol::Environment, + password: &rbw::locked::Password, + provider: rbw::api::TwoFactorProviderType, + ) -> anyhow::Result { + let email = self.email()?; + + with_retry(|err| async move { + let code = self.get_code(provider, &err, environment).await?; + let code = std::str::from_utf8(code.password()).context("code was not valid utf8")?; + + Ok(rbw::actions::login(email, password, Some(code), Some(provider)).await?) + }) + .await + } + + async fn two_factor_required( + &self, + password: &rbw::locked::Password, + providers: Vec, + sso_email_2fa_session_token: Option, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result { + let supported_types = [ + rbw::api::TwoFactorProviderType::Authenticator, + rbw::api::TwoFactorProviderType::Yubikey, + rbw::api::TwoFactorProviderType::Email, + ]; + + let Some(provider) = supported_types.into_iter().find(|p| providers.contains(p)) else { + return Err(anyhow::anyhow!( + "unsupported two factor methods: {providers:?}" + )); + }; + + let email = self.email()?; + + if provider == rbw::api::TwoFactorProviderType::Email { + log::trace!("Two factor provider is email"); + if let Some(token) = sso_email_2fa_session_token { + log::trace!("Sending 2FA email"); + rbw::actions::send_two_factor_email(email, &token).await?; + } + } + + log::trace!("Performing 2FA login"); + + let creds = self.two_factor(environment, password, provider).await?; + + Ok(creds) + } + + async fn get_password( + &self, + desc: &str, + err: &Option, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result { + self.getpin("Master Password", desc, err, environment, true) + .await + .context("failed to read password from pinentry") + } + + pub async fn login( + &self, + sock: &mut crate::sock::Sock, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result<()> { + if !self.inner.db.read().await.needs_login() { + return respond_ack(sock).await; + } + + let host = &self.get_host()?; + + let email = &self.email()?.to_string(); + + let (creds, password) = with_retry(|err| async move { + let password = self + .get_password(&format!("Log in to {host}"), &err, environment) + .await?; + + let r = match rbw::actions::login(email, &password, None, None).await { + Err(Error::TwoFactorRequired { + providers, + sso_email_2fa_session_token, + }) => { + log::trace!("Login requires 2FA, performing it."); + + let ret = match self + .two_factor_required( + &password, + providers, + sso_email_2fa_session_token, + environment, + ) + .await + { + Ok(creds) => Ok((creds, password)), + Err(e) => Err(anyhow::anyhow!("2FA verification failed: {e}")), + }?; + + Ok(ret) + } + Ok(creds) => Ok((creds, password)), + Err(e) => Err(e), + }; + + Ok(r?) + }) + .await + .context("failed to log in to bitwarden instance")?; + + log::debug!("Login successful. Applying session parameters.."); + + { + let mut db = self.inner.db.write().await; + + db.apply_session_parameters(&creds); + + db.save_async(&self.server_name(), self.email()?).await?; + } + + log::trace!("Session parameters set. Syncing.."); + self.sync(None).await?; + + log::trace!("Sync performed. Trying to unlock with the current password.."); + + self.try_unlock(&password) + .await + .context("failed to unlock database")?; + + log::trace!("Login and unlock successful!"); + + respond_ack(sock).await?; + + Ok(()) + } + + pub async fn unlock( + &self, + sock: &mut crate::sock::Sock, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result<()> { + self.unlock_state(environment).await?; + + respond_ack(sock).await?; + + Ok(()) + } + + pub async fn lock(&self, sock: &mut crate::sock::Sock) -> anyhow::Result<()> { + self.clear().await; + + respond_ack(sock).await?; + + Ok(()) + } + + pub async fn check_lock(&self, sock: &mut crate::sock::Sock) -> anyhow::Result<()> { + if self.needs_unlock().await { + return Err(anyhow::anyhow!("agent is locked")); + } + + respond_ack(sock).await?; + + Ok(()) + } + + pub async fn sync(&self, sock: Option<&mut crate::sock::Sock>) -> anyhow::Result<()> { + // Sync is the only one that reads an updated copy of the db from disk + let db = Db::load_async(&self.server_name(), self.email()?).await?; + log::trace!("Read fresh db from disk"); + + let Some(access_token) = &db.access_token else { + anyhow::bail!("failed to find access token in db"); + }; + + let Some(refresh_token) = &db.refresh_token else { + anyhow::bail!("failed to find refresh token in db"); + }; + + log::trace!("Obtained access and refresh tokens"); + + let ( + access_token, + refresh_token_new, + (protected_key, protected_private_key, protected_org_keys, entries), + ) = rbw::actions::sync(access_token, refresh_token) + .await + .context("failed to sync database from server")?; + + log::trace!("Sync operation finished"); + + self.set_master_password_reprompt(&entries).await; + + log::trace!("Set master password reprompt"); + + // And then update the local cached copy of the db + { + let mut db = self.inner.db.write().await; + + log::trace!("Opened cached db for write operation"); + + db.update_access_token(access_token); + db.update_refresh_token(refresh_token_new); + + db.protected_key = Some(protected_key); + db.protected_private_key = Some(protected_private_key); + db.protected_org_keys = protected_org_keys; + db.entries = entries; + + db.save_async(&self.server_name(), self.email()?).await?; + } + + log::trace!("Updated disk db"); + + if let Err(e) = self.subscribe_to_notifications().await { + eprintln!("failed to subscribe to notifications: {e}"); + } + + if let Some(sock) = sock { + respond_ack(sock).await?; + } + + Ok(()) + } + + async fn decrypt_cipher( + &self, + environment: &rbw::protocol::Environment, + cipherstring: &str, + entry_key: Option<&str>, + org_id: Option<&str>, + ) -> anyhow::Result { + self.initialize_mpr().await; + + let Some(keys) = self.key(org_id).await else { + return Err(anyhow::anyhow!( + "failed to find decryption keys in in-memory state" + )); + }; + + let entry_key = decrypt_entry_key(entry_key, keys.as_ref())?; + + self.maybe_reprompt_password(environment, cipherstring) + .await?; + + let cipherstring = rbw::cipherstring::CipherString::new(cipherstring) + .context("failed to parse encrypted secret")?; + + // BUG: This is sensible memory and should be handled more carefully (locked) + let plaintext = String::from_utf8( + cipherstring + .decrypt_symmetric(keys.as_ref(), entry_key.as_ref()) + .context("failed to decrypt encrypted secret")?, + ) + .context("failed to parse decrypted secret")?; + + Ok(plaintext) + } + + pub async fn decrypt( + &self, + sock: &mut crate::sock::Sock, + environment: &rbw::protocol::Environment, + cipherstring: &str, + entry_key: Option<&str>, + org_id: Option<&str>, + ) -> anyhow::Result<()> { + let plaintext = self + .decrypt_cipher(environment, cipherstring, entry_key, org_id) + .await?; + + sock.send(&rbw::protocol::Response::Decrypt { plaintext }) + .await?; + + Ok(()) + } + + pub async fn encrypt( + &self, + sock: &mut crate::sock::Sock, + plaintext: &str, + org_id: Option<&str>, + ) -> anyhow::Result<()> { + let Some(keys) = self.key(org_id).await else { + return Err(anyhow::anyhow!( + "failed to find encryption keys in in-memory state" + )); + }; + + let cipherstring = + rbw::cipherstring::CipherString::encrypt_symmetric(keys.as_ref(), plaintext.as_bytes()) + .context("failed to encrypt plaintext secret")?; + + sock.send(&rbw::protocol::Response::Encrypt { + cipherstring: cipherstring.to_string(), + }) + .await?; + + Ok(()) + } + + #[cfg(feature = "clipboard")] + pub async fn clipboard_store( + &self, + sock: &mut crate::sock::Sock, + text: &str, + ) -> anyhow::Result<()> { + if let Some(clipboard) = &mut (*self.clipboard_mut().await) { + clipboard + .set_text(text) + .map_err(|e| anyhow::anyhow!("couldn't store value to clipboard: {e}"))?; + } + + respond_ack(sock).await?; + + Ok(()) + } + + #[cfg(not(feature = "clipboard"))] + + pub async fn clipboard_store( + &self, + sock: &mut crate::sock::Sock, + _text: &str, + ) -> anyhow::Result<()> { + sock.send(&rbw::protocol::Response::Error { + error: "clipboard not supported".to_string(), + }) + .await?; + + Ok(()) + } + + async fn try_unlock(&self, password: &rbw::locked::Password) -> Result<()> { + let db = self.inner.db.read().await; + + let (protected_key, protected_private_key, protected_org_keys) = + db.some_protected_keys() + .ok_or(Error::UnavailableDbProtectedKeys)?; + + let (keys, org_keys) = rbw::actions::unlock( + self.email()?, + password, + &db.get_crypto_parameters()?, + protected_key, + protected_private_key, + protected_org_keys, + )?; + + self.set_keys(keys, org_keys).await; + + Ok(()) + } + + async fn unlock_state(&self, environment: &rbw::protocol::Environment) -> anyhow::Result<()> { + if self.needs_unlock().await { + with_retry(|err| async move { + let password = self + .get_password( + &format!("Unlock the local database for '{}'", rbw::dirs::profile()), + &err, + environment, + ) + .await?; + + Ok(self.try_unlock(&password).await?) + }) + .await + .context("failed to unlock database")?; + } + + Ok(()) + } + + async fn maybe_reprompt_password( + &self, + environment: &rbw::protocol::Environment, + cipherstring: &str, + ) -> anyhow::Result<()> { + let mut sha256 = sha2::Sha256::new(); + sha256.update(cipherstring); + let master_password_reprompt: [u8; 32] = sha256.finalize().into(); + + if self + .inner + .master_password_reprompt + .read() + .await + .contains(&master_password_reprompt) + { + log::trace!( + "Requesting password reprompt for item {:#?}", + master_password_reprompt + .iter() + .map(|b| format!("{:02x}", b)) + .collect::() + ); + + with_retry(|err| async move { + let password = self + .get_password( + "Accessing this entry requires the master password", + &err, + environment, + ) + .await?; + + Ok(self.try_unlock(&password).await?) + }) + .await + .context("failed to unlock database")?; + + log::trace!("Password correct, reprompt successful"); + } + + Ok(()) + } + + pub async fn get_ssh_public_keys(&self) -> anyhow::Result> { + let environment = { self.last_environment().await.clone() }; + + log::trace!("Resetting lock timeout due to get_ssh_public_keys"); + self.reset_lock_timeout().await; + + log::trace!("Trying to unlock state"); + self.unlock_state(&environment).await?; + + let db = self.inner.db.read().await; + + let enc_pubkeys: Vec<(String, Option, Option)> = db + .entries + .iter() + .filter_map(|e| { + if let EntryData::SshKey { + public_key: Some(pubkey), + .. + } = &e.data + { + Some((pubkey.clone(), e.key.clone(), e.org_id.clone())) + } else { + None + } + }) + .collect(); + + drop(db); + + let mut pubkeys = vec![]; + + for (e, entry_key, org_id) in enc_pubkeys { + let pubkey = self + .decrypt_cipher(&environment, &e, entry_key.as_deref(), org_id.as_deref()) + .await?; + pubkeys.push(pubkey); + } + + Ok(pubkeys) + } + + pub async fn find_ssh_private_key( + &self, + request_public_key: ssh_agent_lib::ssh_key::PublicKey, + ) -> anyhow::Result { + let environment = { + let le = self.last_environment().await; + self.reset_lock_timeout().await; + le.clone() + }; + + self.unlock_state(&environment).await?; + + let request_bytes = request_public_key.to_bytes(); + + let db = self.inner.db.read().await; + + // Collect all ssh keys that are Some() + let keys: Vec<(&String, &String, &Option, &Option)> = db + .entries + .iter() + .filter_map(|e| match &e.data { + rbw::db::EntryData::SshKey { + private_key, + public_key, + .. + } => match (public_key, private_key) { + (Some(public), Some(private)) => Some((public, private, &e.key, &e.org_id)), + _ => None, + }, + _ => None, + }) + .collect(); + + for (public, private, key, org_id) in keys { + let pub_plain = self + .decrypt_cipher(&environment, public, key.as_deref(), org_id.as_deref()) + .await?; + + let pub_bytes = ssh_agent_lib::ssh_key::PublicKey::from_openssh(&pub_plain)?.to_bytes(); + + if pub_bytes != request_bytes { + continue; + } + + let priv_plain = self + .decrypt_cipher(&environment, private, key.as_deref(), org_id.as_deref()) + .await?; + + return ssh_agent_lib::ssh_key::PrivateKey::from_openssh(priv_plain) + .map_err(anyhow::Error::new); + } + + Err(anyhow::anyhow!("No matching private key found")) + } + + pub async fn subscribe_to_notifications(&self) -> anyhow::Result<()> { + if self.notifications_handler().await.is_connected() { + return Ok(()); + } + + let notifications_url = self.notifications_url(); + + let db = self.inner.db.read().await; + + let Some(access_token) = &db.access_token else { + anyhow::bail!("Error getting access token"); + }; + + let websocket_url = format!("{}/hub?access_token={}", notifications_url, access_token) + .replace("https://", "wss://"); + + drop(db); + + let mut nh = self.notifications_handler_mut().await; + + nh.connect(websocket_url) + .await + .err() + .map_or_else(|| Ok(()), |err| Err(anyhow::anyhow!(err.to_string()))) + } +} + +fn decrypt_entry_key( + entry_key: Option<&str>, + keys: &rbw::locked::Keys, +) -> anyhow::Result> { + entry_key + .map(|ek| { + let cs = rbw::cipherstring::CipherString::new(ek) + .context("failed to parse individual item encryption key")?; + Ok(rbw::locked::Keys::new( + cs.decrypt_locked_symmetric(keys) + .context("failed to decrypt individual item encryption key")?, + )) + }) + .transpose() +} + +async fn respond_ack(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { + sock.send(&rbw::protocol::Response::Ack).await?; + + Ok(()) +} diff --git a/src/bin/rbw-agent/agent/mod.rs b/src/bin/rbw-agent/agent/mod.rs new file mode 100644 index 00000000..044ede95 --- /dev/null +++ b/src/bin/rbw-agent/agent/mod.rs @@ -0,0 +1,503 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::{atomic::AtomicBool, Arc}, + time::Duration, +}; + +use anyhow::Context as _; +use rbw::{ + db::Db, + error::{Error, Result}, +}; +use sha2::Digest as _; +use tokio::{ + net::{UnixListener, UnixStream}, + sync::{Mutex, Notify, RwLock, RwLockReadGuard, RwLockWriteGuard}, + time::{sleep_until, Instant}, +}; + +use crate::notifications::NotificationsHandler; + +mod actions; +pub mod ssh_agent; + +struct InnerAgent { + priv_key: RwLock>>, + org_keys: RwLock>>>, + notifications_handler: RwLock, + pub lock_deadline: Mutex>, + pub sync_deadline: Mutex>, + pub run_notify: Notify, + pub master_password_reprompt: RwLock>, + master_password_reprompt_initialized: AtomicBool, + config: rbw::config::Config, + pub db: RwLock, + + // this is stored here specifically for the use of the ssh agent, because + // requests made to the ssh agent don't include an environment, and so we + // can't properly initialize the pinentry process. we work around this by + // just reusing the last environment we saw being sent to the main agent + // (there should be at least one in most cases because you need to start + // the rbw agent in order to make it start serving on the ssh agent + // socket, and that initial request should come with an environment). + // + // we should not use this for any requests on the main agent, those + // should all send their own environment over. + pub last_environment: RwLock, + + #[cfg(feature = "clipboard")] + pub clipboard: Mutex>, +} + +#[derive(Clone)] +pub struct Agent { + inner: Arc, +} + +impl Agent { + pub async fn new(config: rbw::config::Config) -> Self { + let notifications_handler = crate::notifications::NotificationsHandler::new(); + + // TODO: ugly + let mut sync_deadline: Option = None; + let sync_timeout_duration = std::time::Duration::from_secs(config.sync_interval); + + if sync_timeout_duration > std::time::Duration::ZERO { + sync_deadline = Some(Instant::now() + sync_timeout_duration); + } + + let db = match &config.email { + Some(email) => Db::load_async(&config.server_name(), email) + .await + .unwrap_or_else(|_| Db::new()), + None => Db::new(), + }; + + let state = Self { + inner: Arc::new(InnerAgent { + priv_key: RwLock::new(None), + org_keys: RwLock::new(None), + notifications_handler: RwLock::new(notifications_handler), + lock_deadline: Mutex::new(None), + sync_deadline: Mutex::new(sync_deadline), + run_notify: Notify::new(), + master_password_reprompt: RwLock::new(std::collections::HashSet::new()), + master_password_reprompt_initialized: AtomicBool::new(false), + config, + db: RwLock::new(db), + last_environment: RwLock::new(rbw::protocol::Environment::default()), + + #[cfg(feature = "clipboard")] + clipboard: Mutex::new( + arboard::Clipboard::new() + .inspect_err(|e| { + log::warn!("couldn't create clipboard context: {e}"); + }) + .ok(), + ), + }), + }; + + state + } + + pub async fn key(&self, org_id: Option<&str>) -> Option> { + match org_id { + Some(id) => self + .inner + .org_keys + .read() + .await + .as_ref() + .and_then(|h| h.get(id).cloned()), + None => self.inner.priv_key.read().await.clone(), + } + } + + pub async fn set_keys( + &self, + priv_key: rbw::locked::Keys, + org_keys: HashMap, + ) { + let mut priv_key_guard = self.inner.priv_key.write().await; + let mut org_keys_guard = self.inner.org_keys.write().await; + + *priv_key_guard = Some(Arc::new(priv_key)); + + let org_keys: HashMap> = org_keys + .into_iter() + .map(|(k, v)| (k, Arc::new(v))) + .collect(); + + *org_keys_guard = Some(org_keys); + } + + pub async fn needs_unlock(&self) -> bool { + self.inner.priv_key.read().await.is_none() || self.inner.org_keys.read().await.is_none() + } + + pub async fn reset_lock_timeout(&self) { + *self.inner.lock_deadline.lock().await = + Some(Instant::now() + Duration::from_secs(self.inner.config.lock_timeout)); + self.inner.run_notify.notify_one(); + } + + pub async fn notifications_handler(&self) -> RwLockReadGuard<'_, NotificationsHandler> { + self.inner.notifications_handler.read().await + } + + pub async fn notifications_handler_mut(&self) -> RwLockWriteGuard<'_, NotificationsHandler> { + self.inner.notifications_handler.write().await + } + + pub async fn clear(&self) { + { + let mut priv_key_guard = self.inner.priv_key.write().await; + let mut org_keys_guard = self.inner.org_keys.write().await; + + *priv_key_guard = None; + *org_keys_guard = None; + } + + *self.inner.lock_deadline.lock().await = None; + } + + pub async fn set_sync_timeout(&self) { + *self.inner.sync_deadline.lock().await = + Some(Instant::now() + Duration::from_secs(self.inner.config.sync_interval)); + // self.inner + // .sync_timeout + // .set(self.inner.sync_timeout_duration); + } + + // the way we structure the client/agent split in rbw makes the master + // password reprompt feature a bit complicated to implement - it would be + // a lot easier to just have the client do the prompting, but that would + // leave it open to someone reading the cipherstring from the local + // database and passing it to the agent directly, bypassing the client. + // the agent is the thing that holds the unlocked secrets, so it also + // needs to be the thing guarding access to master password reprompt + // entries. we only pass individual cipherstrings to the agent though, so + // the agent needs to be able to recognize the cipherstrings that need + // reprompting, without the additional context of the entry they came + // from. in addition, because the reprompt state is stored in the sync db + // in plaintext, we can't just read it from the db directly, because + // someone could just edit the file on disk before making the request. + // + // therefore, the solution we choose here is to keep an in-memory set of + // cipherstrings that we know correspond to entries with master password + // reprompt enabled. this set is only updated when the agent itself does + // a sync, so it can't be bypassed by editing the on-disk file directly. + // if the agent gets a request for any of those cipherstrings that it saw + // marked as master password reprompt during the most recent sync, it + // forces a reprompt. + + async fn add_mpr(&self, s: Option<&str>) { + if let Some(s) = s { + if !s.is_empty() { + let mut hasher = sha2::Sha256::new(); + hasher.update(s); + self.inner + .master_password_reprompt + .write() + .await + .insert(hasher.finalize().into()); + } + } + } + + pub async fn initialize_mpr(&self) { + if !self.master_password_reprompt_initialized() { + self.set_master_password_reprompt(&self.inner.db.read().await.entries) + .await; + } + } + + pub async fn set_master_password_reprompt(&self, entries: &[rbw::db::Entry]) { + self.inner.master_password_reprompt.write().await.clear(); + + for entry in entries { + if !entry.master_password_reprompt() { + continue; + } + + match &entry.data { + rbw::db::EntryData::Login { password, totp, .. } => { + self.add_mpr(password.as_deref()).await; + self.add_mpr(totp.as_deref()).await; + } + rbw::db::EntryData::Card { number, code, .. } => { + self.add_mpr(number.as_deref()).await; + self.add_mpr(code.as_deref()).await; + } + rbw::db::EntryData::Identity { + ssn, + passport_number, + .. + } => { + self.add_mpr(ssn.as_deref()).await; + self.add_mpr(passport_number.as_deref()).await; + } + rbw::db::EntryData::SecureNote => {} + rbw::db::EntryData::SshKey { private_key, .. } => { + self.add_mpr(private_key.as_deref()).await; + } + } + + for field in &entry.fields { + if field.ty == Some(rbw::api::FieldType::Hidden) { + self.add_mpr(field.value.as_deref()).await; + } + } + } + + self.inner + .master_password_reprompt_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + } + + pub fn master_password_reprompt_initialized(&self) -> bool { + self.inner + .master_password_reprompt_initialized + .load(std::sync::atomic::Ordering::Relaxed) + } + + pub async fn last_environment( + &self, + ) -> tokio::sync::RwLockReadGuard<'_, rbw::protocol::Environment> { + self.inner.last_environment.read().await + } + + pub async fn set_last_environment(&self, environment: rbw::protocol::Environment) { + *self.inner.last_environment.write().await = environment; + } + + pub fn email(&self) -> Result<&str> { + self.inner + .config + .email + .as_deref() + .ok_or_else(|| Error::ConfigMissingEmail) + } + + pub fn base_url(&self) -> String { + self.inner.config.base_url() + } + + pub fn config_pinentry(&self) -> &str { + &self.inner.config.pinentry + } + + pub fn notifications_url(&self) -> String { + self.inner.config.notifications_url() + } + + pub fn server_name(&self) -> String { + self.inner.config.server_name() + } + + #[cfg(feature = "clipboard")] + pub async fn clipboard_mut(&self) -> tokio::sync::MutexGuard<'_, Option> { + self.inner.clipboard.lock().await + } + + pub fn confirm_ssh(&self) -> bool { + self.inner.config.confirm_ssh.is_some_and(|o| o) + } + async fn sleep_until_deadline(deadline: Option) { + match deadline { + Some(d) => sleep_until(d).await, + None => std::future::pending().await, + } + } + + async fn on_notification(&self, message: crate::notifications::Message) { + match message { + crate::notifications::Message::Logout => { + log::debug!("Received Logout Message via notification channel"); + self.clear().await; + } + crate::notifications::Message::Sync => { + log::debug!("Received Sync Message via notification channel"); + self.set_sync_timeout().await; + + if let Err(e) = self.sync(None).await { + eprintln!("failed to sync: {e:#}"); + } + } + crate::notifications::Message::Disconnected => { + log::warn!("Notifications websocket disconnected"); + } + } + } + + async fn on_connection(&self, stream: UnixStream) { + let mut sock = crate::sock::Sock::new(stream); + + let self_ref = self.clone(); + + // TODO: Check if does it make sense to handle this in another task + tokio::spawn(async move { + let res = self_ref.handle_request(&mut sock).await; + if let Err(e) = res { + sock.send(&rbw::protocol::Response::Error { + error: format!("{e:#}"), + }) + .await + .expect("failed to send error response to client"); + } + }); + } + + pub async fn run(self, listener: UnixListener) -> anyhow::Result<()> { + let mut nchannel = self.notifications_handler().await.get_channel(); + + match self.subscribe_to_notifications().await { + Ok(_) => { + log::debug!("Successfully subscribed to notifications"); + } + Err(e) => { + log::warn!("Failed to subscribe to notifications: {e}"); + } + }; + + loop { + let lock_deadline = *self.inner.lock_deadline.lock().await; + let sync_deadline = *self.inner.sync_deadline.lock().await; + + tokio::select! { + message = nchannel.recv() => { + match message { + Ok(message) => self.on_notification(message).await, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + log::warn!("notifications channel lagged by {n} messages"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + anyhow::bail!("notifications channel closed"); + } + } + }, + // TODO: The client does like a hundred connections to do basic things. Maybe it + // makes sense to create more comprehensive opcodes. + res = listener.accept() => { + let res = res.context("failed to accept incoming connection")?; + + self.on_connection(res.0).await; + }, + _ = self.inner.run_notify.notified() => { + log::trace!("Waking run loop to re-evaluate deadlines"); + }, + _ = Self::sleep_until_deadline(lock_deadline) => { + log::trace!("Lock deadline reached. Locking the db"); + self.clear().await; + }, + _ = Self::sleep_until_deadline(sync_deadline) => { + //let state = self.state.clone(); + + log::trace!("Sync deadline reached. Syncing the db"); + self.set_sync_timeout().await; + + // this could fail if we aren't logged in, but we + // don't care about that + if let Err(e) = self.sync(None).await { + eprintln!("failed to sync: {e:#}"); + } + + } + } + } + } + + async fn handle_request(&self, sock: &mut crate::sock::Sock) -> anyhow::Result<()> { + let req = match sock.recv().await? { + Ok(msg) => msg, + Err(error) => { + sock.send(&rbw::protocol::Response::Error { error }).await?; + return Ok(()); + } + }; + + let (action, environment) = req.into_parts(); + + if !matches!(action, rbw::protocol::Action::Decrypt { .. }) + && !matches!(action, rbw::protocol::Action::Encrypt { .. }) + { + log::trace!("Start of action: {:?}", &action); + } + + match &action { + rbw::protocol::Action::Register => { + self.register(sock, &environment).await?; + } + rbw::protocol::Action::Login => { + self.login(sock, &environment).await?; + } + rbw::protocol::Action::Unlock => { + self.unlock(sock, &environment).await?; + } + rbw::protocol::Action::CheckLock => { + self.check_lock(sock).await?; + } + rbw::protocol::Action::Lock => { + self.lock(sock).await?; + } + rbw::protocol::Action::Sync => { + self.sync(Some(sock)).await?; + } + // TODO: This alone does not do much, as it's a simple oracle open for everybody, to + // decrypt stuff. + rbw::protocol::Action::Decrypt { + cipherstring, + entry_key, + org_id, + } => { + self.decrypt( + sock, + &environment, + cipherstring, + entry_key.as_deref(), + org_id.as_deref(), + ) + .await?; + } + rbw::protocol::Action::Encrypt { plaintext, org_id } => { + self.encrypt(sock, plaintext, org_id.as_deref()).await?; + } + rbw::protocol::Action::ClipboardStore { text } => { + self.clipboard_store(sock, text).await?; + } + // TODO: It's better to handle the closing more gracefully + rbw::protocol::Action::Quit => { + log::info!("received quit request (environment: {environment:?}); exiting"); + std::process::exit(0); + } + rbw::protocol::Action::Version => { + sock.send(&rbw::protocol::Response::Version { + version: rbw::protocol::VERSION, + }) + .await?; + } + } + + if !matches!(action, rbw::protocol::Action::Decrypt { .. }) + && !matches!(action, rbw::protocol::Action::Encrypt { .. }) + { + log::trace!("End of action: {:?}", &action); + } + + self.set_last_environment(environment).await; + + // Reset lock timeout on these request types + match &action { + rbw::protocol::Action::Register + | rbw::protocol::Action::Login + | rbw::protocol::Action::Unlock + | rbw::protocol::Action::Decrypt { .. } + | rbw::protocol::Action::Encrypt { .. } + | rbw::protocol::Action::ClipboardStore { .. } => self.reset_lock_timeout().await, + _ => {} + } + + Ok(()) + } +} diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/agent/ssh_agent.rs similarity index 64% rename from src/bin/rbw-agent/ssh_agent.rs rename to src/bin/rbw-agent/agent/ssh_agent.rs index 4d0bb50c..2a7952b2 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/agent/ssh_agent.rs @@ -1,26 +1,25 @@ use signature::{RandomizedSigner as _, SignatureEncoding as _, Signer as _}; +use tokio::net::UnixListener; const SSH_AGENT_RSA_SHA2_256: u32 = 2; const SSH_AGENT_RSA_SHA2_512: u32 = 4; #[derive(Clone)] pub struct SshAgent { - state: std::sync::Arc>, + agent: crate::agent::Agent, } impl SshAgent { - pub fn new( - state: std::sync::Arc>, - ) -> Self { - Self { state } + pub fn new(agent: crate::agent::Agent) -> Self { + Self { agent } } pub async fn run(self) -> anyhow::Result<()> { - let socket = rbw::dirs::ssh_agent_socket_file(); + let socket = rbw::dirs::ssh_agent_socket_file()?; let _ = std::fs::remove_file(&socket); // Ignore error if it doesn't exist - let listener = tokio::net::UnixListener::bind(socket)?; + let listener = UnixListener::bind(socket)?; ssh_agent_lib::agent::listen(listener, self).await?; Ok(()) @@ -31,11 +30,11 @@ impl SshAgent { impl ssh_agent_lib::agent::Session for SshAgent { async fn request_identities( &mut self, - ) -> Result< - Vec, - ssh_agent_lib::error::AgentError, - > { - crate::actions::get_ssh_public_keys(self.state.clone()) + ) -> Result, ssh_agent_lib::error::AgentError> { + log::debug!("Received SSH identities request"); + + self.agent + .get_ssh_public_keys() .await .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))? .into_iter() @@ -53,19 +52,38 @@ impl ssh_agent_lib::agent::Session for SshAgent { async fn sign( &mut self, request: ssh_agent_lib::proto::SignRequest, - ) -> Result< - ssh_agent_lib::ssh_key::Signature, - ssh_agent_lib::error::AgentError, - > { - let pubkey = - ssh_agent_lib::ssh_key::PublicKey::new(request.pubkey, ""); - - let private_key = - crate::actions::find_ssh_private_key(self.state.clone(), pubkey) - .await - .map_err(|e| { - ssh_agent_lib::error::AgentError::Other(e.into()) - })?; + ) -> Result { + let pubkey = ssh_agent_lib::ssh_key::PublicKey::new(request.pubkey, ""); + + log::debug!( + "Received SSH signature request for {}", + pubkey + .to_openssh() + .map_err(ssh_agent_lib::error::AgentError::other)? + ); + + let private_key = self + .agent + .find_ssh_private_key(pubkey) + .await + .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))?; + + if self.agent.confirm_ssh() { + let confirmed = rbw::pinentry::confirm( + self.agent.config_pinentry(), + "Allow SSH key use?", + &self.agent.last_environment().await.clone(), + true, + ) + .await + .map_err(|_| ssh_agent_lib::error::AgentError::Failure)?; + + if !confirmed { + return Err(ssh_agent_lib::error::AgentError::Other( + "User did not confirm".into(), + )); + } + } match private_key.key_data() { ssh_agent_lib::ssh_key::private::KeypairData::Ed25519(key) => key @@ -81,31 +99,23 @@ impl ssh_agent_lib::agent::Session for SshAgent { let mut rng = rand_8::rngs::OsRng; - let (algorithm, sig_bytes) = if request.flags - & SSH_AGENT_RSA_SHA2_512 - != 0 - { - let signing_key = - rsa::pkcs1v15::SigningKey::::new( - rsa_key, - ); + let (algorithm, sig_bytes) = if request.flags & SSH_AGENT_RSA_SHA2_512 != 0 { + let signing_key = rsa::pkcs1v15::SigningKey::::new(rsa_key); let signature = signing_key .try_sign_with_rng(&mut rng, &request.data) .map_err(ssh_agent_lib::error::AgentError::other)?; ("rsa-sha2-512", signature.to_bytes()) } else if request.flags & SSH_AGENT_RSA_SHA2_256 != 0 { - let signing_key = - rsa::pkcs1v15::SigningKey::::new( - rsa_key, - ); + let signing_key = rsa::pkcs1v15::SigningKey::::new(rsa_key); let signature = signing_key .try_sign_with_rng(&mut rng, &request.data) .map_err(ssh_agent_lib::error::AgentError::other)?; ("rsa-sha2-256", signature.to_bytes()) } else { - let signing_key = rsa::pkcs1v15::SigningKey::::new_unprefixed(rsa_key); + let signing_key = + rsa::pkcs1v15::SigningKey::::new_unprefixed(rsa_key); let signature = signing_key .try_sign_with_rng(&mut rng, &request.data) .map_err(ssh_agent_lib::error::AgentError::other)?; diff --git a/src/bin/rbw-agent/daemon.rs b/src/bin/rbw-agent/daemon.rs index ebc17d35..c439c4f3 100644 --- a/src/bin/rbw-agent/daemon.rs +++ b/src/bin/rbw-agent/daemon.rs @@ -21,15 +21,14 @@ pub fn daemonize(no_daemonize: bool) -> anyhow::Result> { .create(true) .truncate(false) .mode(0o666) - .open(rbw::dirs::pid_file()) + .open(rbw::dirs::pid_file()?) .context("failed to open pid file")?; rustix::fs::flock( &pidfile, rustix::fs::FlockOperation::NonBlockingLockExclusive, ) .context("failed to lock pid file")?; - writeln!(pidfile, "{}", std::process::id()) - .context("failed to write pid file")?; + writeln!(pidfile, "{}", std::process::id()).context("failed to write pid file")?; // don't close the pidfile until the process exits, to ensure it // stays locked std::mem::forget(pidfile); @@ -40,15 +39,15 @@ pub fn daemonize(no_daemonize: bool) -> anyhow::Result> { let stdout = std::fs::OpenOptions::new() .append(true) .create(true) - .open(rbw::dirs::agent_stdout_file())?; + .open(rbw::dirs::agent_stdout_file()?)?; let stderr = std::fs::OpenOptions::new() .append(true) .create(true) - .open(rbw::dirs::agent_stderr_file())?; + .open(rbw::dirs::agent_stderr_file()?)?; let (r, w) = rustix::pipe::pipe()?; let daemonize = daemonize::Daemonize::new() - .pid_file(rbw::dirs::pid_file()) + .pid_file(rbw::dirs::pid_file()?) .stdout(stdout) .stderr(stderr); let res = match daemonize.execute() { diff --git a/src/bin/rbw-agent/debugger.rs b/src/bin/rbw-agent/debugger.rs index 3a104b5a..11d26aa0 100644 --- a/src/bin/rbw-agent/debugger.rs +++ b/src/bin/rbw-agent/debugger.rs @@ -13,7 +13,9 @@ pub fn disable_tracing() -> anyhow::Result<()> { Ok(()) } else { let e = std::io::Error::last_os_error(); - Err(anyhow::anyhow!("failed to disable PTRACE_ATTACH, agent memory may be dumpable by other processes: {e}")) + Err(anyhow::anyhow!( + "failed to disable PTRACE_ATTACH, agent memory may be dumpable by other processes: {e}" + )) } } @@ -21,13 +23,12 @@ pub fn disable_tracing() -> anyhow::Result<()> { pub fn disable_tracing() -> anyhow::Result<()> { // safety: correct arguments to ptrace // https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/ptrace.2.html - let ret = unsafe { - libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) - }; + let ret = unsafe { libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) }; if ret != 0 { let e = std::io::Error::last_os_error(); return Err(anyhow::anyhow!( - "failed to deny debugger attach, agent memory may be readable by other processes: {}", e + "failed to deny debugger attach, agent memory may be readable by other processes: {}", + e )); } @@ -42,7 +43,8 @@ pub fn disable_tracing() -> anyhow::Result<()> { if ret != 0 { let e = std::io::Error::last_os_error(); return Err(anyhow::anyhow!( - "failed to disable core dumps, agent memory may be dumped to disk: {}", e + "failed to disable core dumps, agent memory may be dumped to disk: {}", + e )); } diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index 225fb436..3565d3bd 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -1,18 +1,13 @@ use anyhow::Context as _; +use tokio::signal::unix::{signal, SignalKind}; -mod actions; mod agent; mod daemon; mod debugger; mod notifications; mod sock; -mod ssh_agent; -mod state; -mod timeout; -async fn tokio_main( - startup_ack: Option, -) -> anyhow::Result<()> { +async fn async_main(startup_ack: Option) -> anyhow::Result<()> { let listener = crate::sock::listen()?; if let Some(startup_ack) = startup_ack { @@ -20,90 +15,66 @@ async fn tokio_main( } let config = rbw::config::Config::load()?; - let timeout_duration = - std::time::Duration::from_secs(config.lock_timeout); - let sync_timeout_duration = - std::time::Duration::from_secs(config.sync_interval); - let (timeout, timer_r) = crate::timeout::Timeout::new(); - let (sync_timeout, sync_timer_r) = crate::timeout::Timeout::new(); - if sync_timeout_duration > std::time::Duration::ZERO { - sync_timeout.set(sync_timeout_duration); - } - let notifications_handler = crate::notifications::Handler::new(); - let state = - std::sync::Arc::new(tokio::sync::Mutex::new(crate::state::State { - priv_key: None, - org_keys: None, - timeout, - timeout_duration, - sync_timeout, - sync_timeout_duration, - notifications_handler, - master_password_reprompt: std::collections::HashSet::new(), - master_password_reprompt_initialized: false, - last_environment: rbw::protocol::Environment::default(), - #[cfg(feature = "clipboard")] - clipboard: arboard::Clipboard::new() - .inspect_err(|e| { - log::warn!("couldn't create clipboard context: {e}"); - }) - .ok(), - })); - - let agent = - crate::agent::Agent::new(timer_r, sync_timer_r, state.clone()); - - let ssh_agent = crate::ssh_agent::SshAgent::new(state.clone()); - - tokio::try_join!(agent.run(listener), ssh_agent.run())?; + + let agent = crate::agent::Agent::new(config).await; + + let ssh_agent = crate::agent::ssh_agent::SshAgent::new(agent.clone()); + + let mut sigterm = signal(SignalKind::terminate())?; + let mut sigint = signal(SignalKind::interrupt())?; + + tokio::select!( + res = agent.run(listener) => { + log::error!("agent run loop exited unexpectedly: {res:?}"); + }, + res = ssh_agent.run() => { + log::error!("ssh agent exited unexpectedly: {res:?}"); + }, + _ = sigint.recv() => { + log::warn!("SIGINT received. Closing the application."); + }, + _ = sigterm.recv() => { + log::warn!("SIGTERM received. Closing the application."); + } + ); Ok(()) } -fn real_main() -> anyhow::Result<()> { - env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or("info"), - ) - .init(); - - let no_daemonize = std::env::args() - .nth(1) - .is_some_and(|arg| arg == "--no-daemonize"); +fn main() -> anyhow::Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let mut no_daemonize = false; + for arg in std::env::args().skip(1) { + match arg.as_str() { + "--no-daemonize" => no_daemonize = true, + "--version" | "-V" => { + println!("rbw-agent {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + "--help" | "-h" => { + println!("usage: rbw-agent [--no-daemonize]"); + return Ok(()); + } + _ => { + eprintln!("rbw-agent: unrecognized argument '{arg}'"); + eprintln!("usage: rbw-agent [--no-daemonize]"); + std::process::exit(2); + } + } + } rbw::dirs::make_all()?; - let startup_ack = - daemon::daemonize(no_daemonize).context("failed to daemonize")?; + let startup_ack = daemon::daemonize(no_daemonize).context("failed to daemonize")?; if let Err(e) = debugger::disable_tracing() { log::warn!("{e}"); } - let (w, r) = std::sync::mpsc::channel(); // can't use tokio::main because we need to daemonize before starting the // tokio runloop, or else things break - // unwrap is fine here because there's no good reason that this should - // ever fail - tokio::runtime::Runtime::new().unwrap().block_on(async { - if let Err(e) = tokio_main(startup_ack).await { - // this unwrap is fine because it's the only real option here - w.send(e).unwrap(); - } - }); - - if let Ok(e) = r.recv() { - return Err(e); - } + tokio::runtime::Runtime::new()?.block_on(async { async_main(startup_ack).await })?; Ok(()) } - -fn main() { - let res = real_main(); - - if let Err(e) = res { - // XXX log file? - eprintln!("{e:#}"); - std::process::exit(1); - } -} diff --git a/src/bin/rbw-agent/notifications.rs b/src/bin/rbw-agent/notifications.rs index 44b4c29a..732a65da 100644 --- a/src/bin/rbw-agent/notifications.rs +++ b/src/bin/rbw-agent/notifications.rs @@ -1,174 +1,147 @@ +use std::sync::Arc; + use futures_util::{SinkExt as _, StreamExt as _}; +use tokio::{ + sync::{ + broadcast::{Receiver, Sender}, + oneshot, + }, + task::JoinHandle, +}; #[derive(Clone, Copy, Debug)] pub enum Message { + Disconnected, Sync, Logout, } -pub struct Handler { - write: Option< - futures::stream::SplitSink< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - tokio_tungstenite::tungstenite::Message, - >, - >, - read_handle: Option>, - sending_channels: std::sync::Arc< - tokio::sync::RwLock>>, - >, +fn parse_message(message: tokio_tungstenite::tungstenite::Message) -> Option { + let tokio_tungstenite::tungstenite::Message::Binary(data) = message else { + return None; + }; + + // the first few bytes with the 0x80 bit set, plus one byte terminating the length contain the length of the message + let len_buffer_length = data.iter().position(|&x| (x & 0x80) == 0)? + 1; + + let unpacked_messagepack = rmpv::decode::read_value(&mut &data[len_buffer_length..]).ok()?; + + let unpacked_message = unpacked_messagepack.as_array()?; + let message_type = unpacked_message.first()?.as_u64()?; + let target = unpacked_message.get(3)?.as_str()?; + let args = unpacked_message.get(4)?.as_array()?; + + // invocation + if message_type != 1 { + return None; + } + + if target != "ReceiveMessage" { + return None; + } + + let map = args.first()?.as_map()?; + let (_, ty) = map.iter().find(|(k, _)| k.as_str() == Some("Type"))?; + + match ty.as_i64()? { + 11 => Some(Message::Logout), + _ => Some(Message::Sync), + } +} + +pub struct NotificationsHandler { + disconnect_tx: Option>, + read_handle: Option>, + broadcast: Arc>, } -impl Handler { +impl NotificationsHandler { pub fn new() -> Self { + let (tx, _) = tokio::sync::broadcast::channel(32); + Self { - write: None, + disconnect_tx: None, read_handle: None, - sending_channels: std::sync::Arc::new(tokio::sync::RwLock::new( - Vec::new(), - )), + broadcast: Arc::new(tx), } } - pub async fn connect( + async fn subscribe_ws( &mut self, url: String, - ) -> Result<(), Box> { + ) -> Result<(oneshot::Sender<()>, JoinHandle<()>), Box> { + let url = url::Url::parse(url.as_str())?; + let (mut ws_stream, _response) = tokio_tungstenite::connect_async(url).await?; + + ws_stream + .send(tokio_tungstenite::tungstenite::Message::Text( + "{\"protocol\":\"messagepack\",\"version\":1}\x1e".into(), + )) + .await?; + + let (disconnect_tx, mut disconnect_rx) = tokio::sync::oneshot::channel::<()>(); + + let broadcast = self.broadcast.clone(); + let read_task = tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut disconnect_rx => break, + msg = ws_stream.next() => { + match msg { + Some(Ok(msg)) => { + if let Some(parsed) = parse_message(msg) { + let _ = broadcast.send(parsed); + } + }, + Some(Err(e)) => { + eprintln!("websocket error: {e:?}"); + break; + }, + None => break, + } + } + } + } + + let _ = ws_stream.close(None).await; + let _ = broadcast.send(Message::Disconnected); + }); + + Ok((disconnect_tx, read_task)) + } + + pub async fn connect(&mut self, url: String) -> Result<(), Box> { if self.is_connected() { self.disconnect().await?; } - let (write, read_handle) = - subscribe_to_notifications(url, self.sending_channels.clone()) - .await?; + let (disconnect_tx, read_task) = self.subscribe_ws(url).await?; + + self.disconnect_tx = Some(disconnect_tx); + self.read_handle = Some(read_task); - self.write = Some(write); - self.read_handle = Some(read_handle); Ok(()) } pub fn is_connected(&self) -> bool { - self.write.is_some() + self.disconnect_tx.is_some() && self.read_handle.is_some() && !self.read_handle.as_ref().unwrap().is_finished() } - pub async fn disconnect( - &mut self, - ) -> Result<(), Box> { - self.sending_channels.write().await.clear(); - if let Some(mut write) = self.write.take() { - write - .send(tokio_tungstenite::tungstenite::Message::Close(None)) - .await?; - write.close().await?; + pub async fn disconnect(&mut self) -> Result<(), Box> { + if let Some(disconnect_tx) = self.disconnect_tx.take() { + let _ = disconnect_tx.send(()); self.read_handle.take().unwrap().await?; } - self.write = None; - self.read_handle = None; - Ok(()) - } - - pub async fn get_channel( - &self, - ) -> tokio::sync::mpsc::UnboundedReceiver { - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - self.sending_channels.write().await.push(tx); - rx - } -} - -async fn subscribe_to_notifications( - url: String, - sending_channels: std::sync::Arc< - tokio::sync::RwLock>>, - >, -) -> Result< - ( - futures_util::stream::SplitSink< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - tokio_tungstenite::tungstenite::Message, - >, - tokio::task::JoinHandle<()>, - ), - Box, -> { - let url = url::Url::parse(url.as_str())?; - let (ws_stream, _response) = - tokio_tungstenite::connect_async(url).await?; - let (mut write, read) = ws_stream.split(); - - write - .send(tokio_tungstenite::tungstenite::Message::Text( - "{\"protocol\":\"messagepack\",\"version\":1}\x1e".into(), - )) - .await - .unwrap(); - - let read_future = async move { - let sending_channels = &sending_channels; - read.for_each(|message| async move { - match message { - Ok(message) => { - if let Some(message) = parse_message(message) { - let sending_channels = sending_channels.read().await; - let sending_channels = sending_channels.as_slice(); - for channel in sending_channels { - channel.send(message).unwrap(); - } - } - } - Err(e) => { - eprintln!("websocket error: {e:?}"); - } - } - }) - .await; - }; - - Ok((write, tokio::spawn(read_future))) -} - -fn parse_message( - message: tokio_tungstenite::tungstenite::Message, -) -> Option { - let tokio_tungstenite::tungstenite::Message::Binary(data) = message - else { - return None; - }; - // the first few bytes with the 0x80 bit set, plus one byte terminating the length contain the length of the message - let len_buffer_length = data.iter().position(|&x| (x & 0x80) == 0)? + 1; - - let unpacked_messagepack = - rmpv::decode::read_value(&mut &data[len_buffer_length..]).ok()?; + self.disconnect_tx = None; + self.read_handle = None; - let unpacked_message = unpacked_messagepack.as_array()?; - let message_type = unpacked_message.first()?.as_u64()?; - // invocation - if message_type != 1 { - return None; - } - let target = unpacked_message.get(3)?.as_str()?; - if target != "ReceiveMessage" { - return None; + Ok(()) } - let args = unpacked_message.get(4)?.as_array()?; - let map = args.first()?.as_map()?; - for (k, v) in map { - if k.as_str()? == "Type" { - let ty = v.as_i64()?; - return match ty { - 11 => Some(Message::Logout), - _ => Some(Message::Sync), - }; - } + pub fn get_channel(&self) -> Receiver { + self.broadcast.subscribe() } - - None } diff --git a/src/bin/rbw-agent/sock.rs b/src/bin/rbw-agent/sock.rs index cfbaa55b..2b3aa535 100644 --- a/src/bin/rbw-agent/sock.rs +++ b/src/bin/rbw-agent/sock.rs @@ -8,10 +8,7 @@ impl Sock { Self(s) } - pub async fn send( - &mut self, - res: &rbw::protocol::Response, - ) -> anyhow::Result<()> { + pub async fn send(&mut self, res: &rbw::protocol::Response) -> anyhow::Result<()> { if let rbw::protocol::Response::Error { error } = res { log::warn!("{error}"); } @@ -32,8 +29,7 @@ impl Sock { pub async fn recv( &mut self, - ) -> anyhow::Result> - { + ) -> anyhow::Result> { let Self(sock) = self; let mut buf = tokio::io::BufStream::new(sock); let mut line = String::new(); @@ -46,11 +42,10 @@ impl Sock { } pub fn listen() -> anyhow::Result { - let path = rbw::dirs::socket_file(); + let path = rbw::dirs::socket_file()?; // if the socket already doesn't exist, that's fine let _ = std::fs::remove_file(&path); - let sock = tokio::net::UnixListener::bind(&path) - .context("failed to listen on socket")?; + let sock = tokio::net::UnixListener::bind(&path).context("failed to listen on socket")?; log::debug!("listening on socket {}", path.to_string_lossy()); Ok(sock) } diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs deleted file mode 100644 index 15ba565d..00000000 --- a/src/bin/rbw-agent/state.rs +++ /dev/null @@ -1,146 +0,0 @@ -use sha2::Digest as _; - -pub struct State { - pub priv_key: Option, - pub org_keys: - Option>, - pub timeout: crate::timeout::Timeout, - pub timeout_duration: std::time::Duration, - pub sync_timeout: crate::timeout::Timeout, - pub sync_timeout_duration: std::time::Duration, - pub notifications_handler: crate::notifications::Handler, - pub master_password_reprompt: std::collections::HashSet<[u8; 32]>, - pub master_password_reprompt_initialized: bool, - - // this is stored here specifically for the use of the ssh agent, because - // requests made to the ssh agent don't include an environment, and so we - // can't properly initialize the pinentry process. we work around this by - // just reusing the last environment we saw being sent to the main agent - // (there should be at least one in most cases because you need to start - // the rbw agent in order to make it start serving on the ssh agent - // socket, and that initial request should come with an environment). - // - // we should not use this for any requests on the main agent, those - // should all send their own environment over. - pub last_environment: rbw::protocol::Environment, - - #[cfg(feature = "clipboard")] - pub clipboard: Option, -} - -impl State { - pub fn key(&self, org_id: Option<&str>) -> Option<&rbw::locked::Keys> { - org_id.map_or(self.priv_key.as_ref(), |id| { - self.org_keys.as_ref().and_then(|h| h.get(id)) - }) - } - - pub fn needs_unlock(&self) -> bool { - self.priv_key.is_none() || self.org_keys.is_none() - } - - pub fn set_timeout(&self) { - self.timeout.set(self.timeout_duration); - } - - pub fn clear(&mut self) { - self.priv_key = None; - self.org_keys = None; - self.timeout.clear(); - } - - pub fn set_sync_timeout(&self) { - self.sync_timeout.set(self.sync_timeout_duration); - } - - // the way we structure the client/agent split in rbw makes the master - // password reprompt feature a bit complicated to implement - it would be - // a lot easier to just have the client do the prompting, but that would - // leave it open to someone reading the cipherstring from the local - // database and passing it to the agent directly, bypassing the client. - // the agent is the thing that holds the unlocked secrets, so it also - // needs to be the thing guarding access to master password reprompt - // entries. we only pass individual cipherstrings to the agent though, so - // the agent needs to be able to recognize the cipherstrings that need - // reprompting, without the additional context of the entry they came - // from. in addition, because the reprompt state is stored in the sync db - // in plaintext, we can't just read it from the db directly, because - // someone could just edit the file on disk before making the request. - // - // therefore, the solution we choose here is to keep an in-memory set of - // cipherstrings that we know correspond to entries with master password - // reprompt enabled. this set is only updated when the agent itself does - // a sync, so it can't be bypassed by editing the on-disk file directly. - // if the agent gets a request for any of those cipherstrings that it saw - // marked as master password reprompt during the most recent sync, it - // forces a reprompt. - pub fn set_master_password_reprompt( - &mut self, - entries: &[rbw::db::Entry], - ) { - self.master_password_reprompt.clear(); - - let mut hasher = sha2::Sha256::new(); - let mut insert = |s: Option<&str>| { - if let Some(s) = s { - if !s.is_empty() { - hasher.update(s); - self.master_password_reprompt - .insert(hasher.finalize_reset().into()); - } - } - }; - - for entry in entries { - if !entry.master_password_reprompt() { - continue; - } - - match &entry.data { - rbw::db::EntryData::Login { password, totp, .. } => { - insert(password.as_deref()); - insert(totp.as_deref()); - } - rbw::db::EntryData::Card { number, code, .. } => { - insert(number.as_deref()); - insert(code.as_deref()); - } - rbw::db::EntryData::Identity { - ssn, - passport_number, - .. - } => { - insert(ssn.as_deref()); - insert(passport_number.as_deref()); - } - rbw::db::EntryData::SecureNote => {} - rbw::db::EntryData::SshKey { private_key, .. } => { - insert(private_key.as_deref()); - } - } - - for field in &entry.fields { - if field.ty == Some(rbw::api::FieldType::Hidden) { - insert(field.value.as_deref()); - } - } - } - - self.master_password_reprompt_initialized = true; - } - - pub fn master_password_reprompt_initialized(&self) -> bool { - self.master_password_reprompt_initialized - } - - pub fn last_environment(&self) -> &rbw::protocol::Environment { - &self.last_environment - } - - pub fn set_last_environment( - &mut self, - environment: rbw::protocol::Environment, - ) { - self.last_environment = environment; - } -} diff --git a/src/bin/rbw-agent/timeout.rs b/src/bin/rbw-agent/timeout.rs deleted file mode 100644 index e2aba06d..00000000 --- a/src/bin/rbw-agent/timeout.rs +++ /dev/null @@ -1,66 +0,0 @@ -use futures_util::StreamExt as _; - -#[derive(Debug, Hash, Eq, PartialEq, Copy, Clone)] -enum Streams { - Requests, - Timer, -} - -#[derive(Debug)] -enum Action { - Set(std::time::Duration), - Clear, -} - -pub struct Timeout { - req_w: tokio::sync::mpsc::UnboundedSender, -} - -impl Timeout { - pub fn new() -> (Self, tokio::sync::mpsc::UnboundedReceiver<()>) { - let (req_w, req_r) = tokio::sync::mpsc::unbounded_channel(); - let (timer_w, timer_r) = tokio::sync::mpsc::unbounded_channel(); - tokio::spawn(async move { - enum Event { - Request(Action), - Timer, - } - let mut stream = tokio_stream::StreamMap::new(); - stream.insert( - Streams::Requests, - tokio_stream::wrappers::UnboundedReceiverStream::new(req_r) - .map(Event::Request) - .boxed(), - ); - while let Some(event) = stream.next().await { - match event { - (_, Event::Request(Action::Set(dur))) => { - stream.insert( - Streams::Timer, - futures_util::stream::once(tokio::time::sleep( - dur, - )) - .map(|()| Event::Timer) - .boxed(), - ); - } - (_, Event::Request(Action::Clear)) => { - stream.remove(&Streams::Timer); - } - (_, Event::Timer) => { - timer_w.send(()).unwrap(); - } - } - } - }); - (Self { req_w }, timer_r) - } - - pub fn set(&self, dur: std::time::Duration) { - self.req_w.send(Action::Set(dur)).unwrap(); - } - - pub fn clear(&self) { - self.req_w.send(Action::Clear).unwrap(); - } -} diff --git a/src/bin/rbw/actions.rs b/src/bin/rbw/actions.rs index a0a34e8c..46c4e6ac 100644 --- a/src/bin/rbw/actions.rs +++ b/src/bin/rbw/actions.rs @@ -25,17 +25,14 @@ pub fn unlocked() -> anyhow::Result<()> { let res = sock.recv()?; match res { rbw::protocol::Response::Ack => Ok(()), - rbw::protocol::Response::Error { error } => { - Err(anyhow::anyhow!("{error}")) - } + rbw::protocol::Response::Error { error } => Err(anyhow::anyhow!("{error}")), _ => Err(anyhow::anyhow!("unexpected message: {res:?}")), } } Err(e) => { if matches!( e.kind(), - std::io::ErrorKind::ConnectionRefused - | std::io::ErrorKind::NotFound + std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound ) { anyhow::bail!("agent not running"); } @@ -55,12 +52,10 @@ pub fn lock() -> anyhow::Result<()> { pub fn quit() -> anyhow::Result<()> { match crate::sock::Sock::connect() { Ok(mut sock) => { - let pidfile = rbw::dirs::pid_file(); + let pidfile = rbw::dirs::pid_file()?; let mut pid = String::new(); std::fs::File::open(pidfile)?.read_to_string(&mut pid)?; - let Some(pid) = - rustix::process::Pid::from_raw(pid.trim_end().parse()?) - else { + let Some(pid) = rustix::process::Pid::from_raw(pid.trim_end().parse()?) else { anyhow::bail!("failed to read pid from pidfile"); }; sock.send(&rbw::protocol::Request::new( @@ -73,8 +68,7 @@ pub fn quit() -> anyhow::Result<()> { Err(e) => match e.kind() { // if the socket doesn't exist, or the socket exists but nothing // is listening on it, the agent must already be not running - std::io::ErrorKind::ConnectionRefused - | std::io::ErrorKind::NotFound => Ok(()), + std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound => Ok(()), _ => Err(e.into()), }, } @@ -85,45 +79,28 @@ pub fn decrypt( entry_key: Option<&str>, org_id: Option<&str>, ) -> anyhow::Result { - let mut sock = connect()?; - sock.send(&rbw::protocol::Request::new( - get_environment(), - rbw::protocol::Action::Decrypt { - cipherstring: cipherstring.to_string(), - entry_key: entry_key.map(std::string::ToString::to_string), - org_id: org_id.map(std::string::ToString::to_string), - }, - ))?; + let res = complex_action(rbw::protocol::Action::Decrypt { + cipherstring: cipherstring.to_string(), + entry_key: entry_key.map(std::string::ToString::to_string), + org_id: org_id.map(std::string::ToString::to_string), + })?; - let res = sock.recv()?; match res { rbw::protocol::Response::Decrypt { plaintext } => Ok(plaintext), - rbw::protocol::Response::Error { error } => { - Err(anyhow::anyhow!("failed to decrypt: {error}")) - } + rbw::protocol::Response::Error { error } => Err(anyhow::anyhow!("{error}")), _ => Err(anyhow::anyhow!("unexpected message: {res:?}")), } } -pub fn encrypt( - plaintext: &str, - org_id: Option<&str>, -) -> anyhow::Result { - let mut sock = connect()?; - sock.send(&rbw::protocol::Request::new( - get_environment(), - rbw::protocol::Action::Encrypt { - plaintext: plaintext.to_string(), - org_id: org_id.map(std::string::ToString::to_string), - }, - ))?; +pub fn encrypt(plaintext: &str, org_id: Option<&str>) -> anyhow::Result { + let res = complex_action(rbw::protocol::Action::Encrypt { + plaintext: plaintext.to_string(), + org_id: org_id.map(std::string::ToString::to_string), + })?; - let res = sock.recv()?; match res { rbw::protocol::Response::Encrypt { cipherstring } => Ok(cipherstring), - rbw::protocol::Response::Error { error } => { - Err(anyhow::anyhow!("failed to encrypt: {error}")) - } + rbw::protocol::Response::Error { error } => Err(anyhow::anyhow!("{error}")), _ => Err(anyhow::anyhow!("unexpected message: {res:?}")), } } @@ -135,13 +112,8 @@ pub fn clipboard_store(text: &str) -> anyhow::Result<()> { } pub fn version() -> anyhow::Result { - let mut sock = connect()?; - sock.send(&rbw::protocol::Request::new( - get_environment(), - rbw::protocol::Action::Version, - ))?; + let res = complex_action(rbw::protocol::Action::Version)?; - let res = sock.recv()?; match res { rbw::protocol::Response::Version { version } => Ok(version), rbw::protocol::Response::Error { error } => { @@ -151,17 +123,19 @@ pub fn version() -> anyhow::Result { } } -fn simple_action(action: rbw::protocol::Action) -> anyhow::Result<()> { +fn complex_action(action: rbw::protocol::Action) -> anyhow::Result { let mut sock = connect()?; sock.send(&rbw::protocol::Request::new(get_environment(), action))?; + sock.recv() +} + +fn simple_action(action: rbw::protocol::Action) -> anyhow::Result<()> { + let res = complex_action(action)?; - let res = sock.recv()?; match res { rbw::protocol::Response::Ack => Ok(()), - rbw::protocol::Response::Error { error } => { - Err(anyhow::anyhow!("{error}")) - } + rbw::protocol::Response::Error { error } => Err(anyhow::anyhow!("{error}")), _ => Err(anyhow::anyhow!("unexpected message: {res:?}")), } } @@ -173,7 +147,9 @@ fn connect() -> anyhow::Result { "failed to connect to rbw-agent \ (this often means that the agent failed to start; \ check {} for agent logs)", - log.display() + log.map_or("".to_string(), |p| p + .display() + .to_string()) ) }) } @@ -195,9 +171,7 @@ fn get_environment() -> rbw::protocol::Environment { }); let env_vars = std::env::vars_os() - .filter(|(var_name, _)| { - (*rbw::protocol::ENVIRONMENT_VARIABLES_OS).contains(var_name) - }) + .filter(|(var_name, _)| (*rbw::protocol::ENVIRONMENT_VARIABLES_OS).contains(var_name)) .collect(); rbw::protocol::Environment::new(tty, env_vars) } diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bddf0efe..cb03d688 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1,197 +1,55 @@ -use std::{fmt::Write as _, io::Write as _, os::unix::ffi::OsStrExt as _}; +use std::{io::Write as _, os::unix::ffi::OsStrExt as _, path::PathBuf, time::SystemTime}; use anyhow::Context as _; - -// The default number of seconds the generated TOTP -// code lasts for before a new one must be generated -const TOTP_DEFAULT_STEP: u64 = 30; - -const MISSING_CONFIG_HELP: &str = - "Before using rbw, you must configure the email address you would like to \ - use to log in to the server by running:\n\n \ - rbw config set email \n\n\ - Additionally, if you are using a self-hosted installation, you should \ - run:\n\n \ - rbw config set base_url \n\n\ - and, if your server has a non-default identity url:\n\n \ - rbw config set identity_url \n"; - -#[derive(Debug, Clone)] -pub enum Needle { - Name(String), - Uri(url::Url), - Uuid(uuid::Uuid, String), -} - -impl std::fmt::Display for Needle { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let value = match &self { - Self::Name(name) => name.clone(), - Self::Uri(uri) => uri.to_string(), - Self::Uuid(_, s) => s.clone(), - }; - write!(f, "{value}") - } -} - -#[allow(clippy::unnecessary_wraps)] -pub fn parse_needle(arg: &str) -> Result { - if let Ok(uuid) = uuid::Uuid::parse_str(arg) { - return Ok(Needle::Uuid(uuid, arg.to_string())); - } - if let Ok(url) = url::Url::parse(arg) { - if url.is_special() { - return Ok(Needle::Uri(url)); - } - } - - Ok(Needle::Name(arg.to_string())) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -enum Field { - Notes, - Username, - Password, - Totp, - Uris, - IdentityName, - City, - State, - PostalCode, - Country, - Phone, - Ssn, - License, - Passport, - CardNumber, - Expiration, - ExpMonth, - ExpYear, - Cvv, - Cardholder, - Brand, - Name, - Email, - Address, - Address1, - Address2, - Address3, - Fingerprint, - PublicKey, - PrivateKey, - Title, - FirstName, - MiddleName, - LastName, -} - -impl std::str::FromStr for Field { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - Ok(match s.to_lowercase().as_str() { - "notes" | "note" => Self::Notes, - "username" | "user" => Self::Username, - "password" => Self::Password, - "totp" | "code" => Self::Totp, - "uris" | "urls" | "sites" => Self::Uris, - "identityname" => Self::IdentityName, - "city" => Self::City, - "state" => Self::State, - "postcode" | "zipcode" | "zip" => Self::PostalCode, - "country" => Self::Country, - "phone" => Self::Phone, - "ssn" => Self::Ssn, - "license" => Self::License, - "passport" => Self::Passport, - "number" | "card" => Self::CardNumber, - "exp" => Self::Expiration, - "exp_month" | "month" => Self::ExpMonth, - "exp_year" | "year" => Self::ExpYear, - // the word "code" got preceeded by Totp - "cvv" => Self::Cvv, - "cardholder" | "cardholder_name" => Self::Cardholder, - "brand" | "type" => Self::Brand, - "name" => Self::Name, - "email" => Self::Email, - "address1" => Self::Address1, - "address2" => Self::Address2, - "address3" => Self::Address3, - "address" => Self::Address, - "fingerprint" => Self::Fingerprint, - "public_key" => Self::PublicKey, - "private_key" => Self::PrivateKey, - "title" => Self::Title, - "first_name" => Self::FirstName, - "middle_name" => Self::MiddleName, - "last_name" => Self::LastName, - _ => anyhow::bail!("unknown field {s}"), +use rbw::{ + db::{Decrypted, Decrypter, Encrypted, Encrypter, EntryData}, + search::Needle, +}; + +use crate::FindArgs; + +/// This Encrypter implementation will send the decrypted string to the agent and wait for it to +/// encrypt it. +struct RemoteEncrypter {} + +impl rbw::db::Encrypter for RemoteEncrypter { + fn encrypt_field( + &mut self, + entry: Option<&rbw::db::Entry>, + field: &str, + ) -> rbw::error::Result { + crate::actions::encrypt(field, entry.and_then(|e| e.org_id.as_deref())).map_err(|e| { + rbw::error::Error::EncryptRemote { + message: format!("{e:#}"), + } }) } } -impl Field { - fn as_str(&self) -> &str { - match self { - Self::Notes => "notes", - Self::Username => "username", - Self::Password => "password", - Self::Totp => "totp", - Self::Uris => "uris", - Self::IdentityName => "identityname", - Self::City => "city", - Self::State => "state", - Self::PostalCode => "postcode", - Self::Country => "country", - Self::Phone => "phone", - Self::Ssn => "ssn", - Self::License => "license", - Self::Passport => "passport", - Self::CardNumber => "number", - Self::Expiration => "exp", - Self::ExpMonth => "exp_month", - Self::ExpYear => "exp_year", - Self::Cvv => "cvv", - Self::Cardholder => "cardholder", - Self::Brand => "brand", - Self::Name => "name", - Self::Email => "email", - Self::Address1 => "address1", - Self::Address2 => "address2", - Self::Address3 => "address3", - Self::Address => "address", - Self::Fingerprint => "fingerprint", - Self::PublicKey => "public_key", - Self::PrivateKey => "private_key", - Self::Title => "title", - Self::FirstName => "first_name", - Self::MiddleName => "middle_name", - Self::LastName => "last_name", - } - } -} - -impl std::fmt::Display for Field { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) +/// This Decrypter implementation will send the encrypted string to the agent and wait for it to +/// decrypt it. +struct RemoteDecrypter {} + +impl rbw::db::Decrypter for RemoteDecrypter { + fn decrypt_field( + &mut self, + entry: Option<&rbw::db::Entry>, + field: &str, + ) -> rbw::error::Result { + crate::actions::decrypt( + field, + entry.and_then(|e| e.key.as_deref()), + entry.and_then(|e| e.org_id.as_deref()), + ) + .map_err(|e| rbw::error::Error::DecryptRemote { + message: format!("{e:#}"), + }) } } -#[derive(Debug, serde::Serialize)] -struct DecryptedListCipher { - id: String, - name: Option, - user: Option, - folder: Option, - uris: Option>, - #[serde(rename = "type")] - entry_type: Option, -} - #[derive(Debug, Clone, serde::Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] -struct DecryptedSearchCipher { +struct SearchEntry { id: String, #[serde(rename = "type")] entry_type: String, @@ -203,14 +61,14 @@ struct DecryptedSearchCipher { notes: Option, } -impl DecryptedSearchCipher { +impl SearchEntry { fn display_name(&self) -> String { - self.user.as_ref().map_or_else( - || self.name.clone(), - |user| format!("{user}@{}", self.name), - ) + self.user + .as_ref() + .map_or_else(|| self.name.clone(), |user| format!("{user}@{}", self.name)) } + #[allow(clippy::too_many_arguments)] fn matches( &self, needle: &Needle, @@ -222,18 +80,14 @@ impl DecryptedSearchCipher { exact: bool, ) -> bool { let match_str = match (ignore_case, exact) { - (true, true) => |field: &str, search_term: &str| { - field.to_lowercase() == search_term.to_lowercase() - }, + (true, true) => { + |field: &str, search_term: &str| field.to_lowercase() == search_term.to_lowercase() + } (true, false) => |field: &str, search_term: &str| { field.to_lowercase().contains(&search_term.to_lowercase()) }, - (false, true) => { - |field: &str, search_term: &str| field == search_term - } - (false, false) => { - |field: &str, search_term: &str| field.contains(search_term) - } + (false, true) => |field: &str, search_term: &str| field == search_term, + (false, false) => |field: &str, search_term: &str| field.contains(search_term), }; match (self.folder.as_deref(), folder) { @@ -272,9 +126,7 @@ impl DecryptedSearchCipher { match needle { Needle::Uuid(uuid, s) => { - if uuid::Uuid::parse_str(&self.id) != Ok(*uuid) - && !match_str(&self.name, s) - { + if uuid::Uuid::parse_str(&self.id) != Ok(*uuid) && !match_str(&self.name, s) { return false; } } @@ -284,9 +136,11 @@ impl DecryptedSearchCipher { } } Needle::Uri(given_uri) => { - if self.uris.iter().all(|(uri, match_type)| { - !matches_url(uri, *match_type, given_uri) - }) { + if self + .uris + .iter() + .all(|(uri, match_type)| !matches_url(uri, *match_type, given_uri)) + { return false; } } @@ -296,901 +150,137 @@ impl DecryptedSearchCipher { } fn search_match(&self, term: &str, folder: Option<&str>) -> bool { - if let Some(folder) = folder { - if self.folder.as_deref() != Some(folder) { - return false; - } - } - - let mut fields = vec![self.name.clone()]; - if let Some(notes) = &self.notes { - fields.push(notes.clone()); - } - if let Some(user) = &self.user { - fields.push(user.clone()); + if folder.is_some() && self.folder.as_deref() != folder { + return false; } - fields.extend(self.uris.iter().map(|(uri, _)| uri).cloned()); - fields.extend(self.fields.iter().cloned()); - for field in fields { - if field.to_lowercase().contains(&term.to_lowercase()) { - return true; - } - } + let term = term.to_lowercase(); - false + [Some(&self.name), self.notes.as_ref(), self.user.as_ref()] + .into_iter() + .flatten() + .chain(self.uris.iter().map(|(uri, _)| uri)) + .chain(self.fields.iter()) + .any(|f| f.to_lowercase().contains(&term)) } } -impl From for DecryptedListCipher { - fn from(value: DecryptedSearchCipher) -> Self { - Self { - id: value.id, - entry_type: Some(value.entry_type), - name: Some(value.name), - user: value.user, - folder: value.folder, - uris: Some(value.uris.into_iter().map(|(s, _)| s).collect()), - } - } -} +impl TryFrom<&rbw::db::Entry> for SearchEntry { + type Error = anyhow::Error; -#[derive(Debug, Clone, serde::Serialize)] -#[cfg_attr(test, derive(Eq, PartialEq))] -struct DecryptedCipher { - id: String, - folder: Option, - name: String, - data: DecryptedData, - fields: Vec, - notes: Option, - history: Vec, -} + fn try_from(entry: &rbw::db::Entry) -> Result { + let mut dec = RemoteDecrypter {}; -impl DecryptedCipher { - fn display_short(&self, desc: &str, clipboard: bool) -> bool { - match &self.data { - DecryptedData::Login { password, .. } => { - password.as_ref().map_or_else( - || { - eprintln!("entry for '{desc}' had no password"); - false - }, - |password| val_display_or_store(clipboard, password), - ) - } - DecryptedData::Card { number, .. } => { - number.as_ref().map_or_else( - || { - eprintln!("entry for '{desc}' had no card number"); - false - }, - |number| val_display_or_store(clipboard, number), - ) - } - DecryptedData::Identity { - title, - first_name, - middle_name, - last_name, - .. - } => { - let names: Vec<_> = - [title, first_name, middle_name, last_name] - .iter() - .copied() - .flatten() - .cloned() - .collect(); - if names.is_empty() { - eprintln!("entry for '{desc}' had no name"); - false - } else { - val_display_or_store(clipboard, &names.join(" ")) - } - } - DecryptedData::SecureNote => self.notes.as_ref().map_or_else( - || { - eprintln!("entry for '{desc}' had no notes"); - false - }, - |notes| val_display_or_store(clipboard, notes), - ), - DecryptedData::SshKey { public_key, .. } => { - public_key.as_ref().map_or_else( - || { - eprintln!("entry for '{desc}' had no public key"); - false - }, - |public_key| val_display_or_store(clipboard, public_key), - ) - } - } - } + let user = match &entry.data { + EntryData::Login { username, .. } => entry.decrypt_optstring(username, &mut dec)?, + _ => None, + }; - fn display_field(&self, desc: &str, field: &str, clipboard: bool) { - let field = field.to_lowercase(); - let field = field.as_str(); - match &self.data { - DecryptedData::Login { - username, - totp, - uris, - .. - } => match field.parse() { - Ok(Field::Notes) => { - if let Some(notes) = &self.notes { - val_display_or_store(clipboard, notes); - } - } - Ok(Field::Username) => { - if let Some(username) = &username { - val_display_or_store(clipboard, username); - } - } - Ok(Field::Totp) => { - if let Some(totp) = totp { - match generate_totp(totp) { - Ok(code) => { - val_display_or_store(clipboard, &code); - } - Err(e) => { - eprintln!("{e}"); - } - } - } - } - Ok(Field::Uris) => { - if let Some(uris) = uris { - let uri_strs: Vec<_> = - uris.iter().map(|uri| uri.uri.clone()).collect(); - val_display_or_store(clipboard, &uri_strs.join("\n")); - } - } - Ok(Field::Password) => { - self.display_short(desc, clipboard); - } - _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); - break; - } - } - } - } - }, - DecryptedData::Card { - cardholder_name, - brand, - exp_month, - exp_year, - code, - .. - } => match field.parse() { - Ok(Field::CardNumber) => { - self.display_short(desc, clipboard); - } - Ok(Field::Expiration) => { - if let (Some(month), Some(year)) = (exp_month, exp_year) { - val_display_or_store( - clipboard, - &format!("{month}/{year}"), - ); - } - } - Ok(Field::ExpMonth) => { - if let Some(exp_month) = exp_month { - val_display_or_store(clipboard, exp_month); - } - } - Ok(Field::ExpYear) => { - if let Some(exp_year) = exp_year { - val_display_or_store(clipboard, exp_year); - } - } - Ok(Field::Cvv) => { - if let Some(code) = code { - val_display_or_store(clipboard, code); - } - } - Ok(Field::Name | Field::Cardholder) => { - if let Some(cardholder_name) = cardholder_name { - val_display_or_store(clipboard, cardholder_name); - } - } - Ok(Field::Brand) => { - if let Some(brand) = brand { - val_display_or_store(clipboard, brand); - } - } - Ok(Field::Notes) => { - if let Some(notes) = &self.notes { - val_display_or_store(clipboard, notes); - } - } - _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); - break; - } - } - } - } - }, - DecryptedData::Identity { - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - .. - } => match field.parse() { - Ok(Field::Name) => { - self.display_short(desc, clipboard); - } - Ok(Field::Email) => { - if let Some(email) = email { - val_display_or_store(clipboard, email); - } - } - Ok(Field::Address) => { - let mut strs = vec![]; - if let Some(address1) = address1 { - strs.push(address1.clone()); - } - if let Some(address2) = address2 { - strs.push(address2.clone()); - } - if let Some(address3) = address3 { - strs.push(address3.clone()); - } - if !strs.is_empty() { - val_display_or_store(clipboard, &strs.join("\n")); - } - } - Ok(Field::City) => { - if let Some(city) = city { - val_display_or_store(clipboard, city); - } - } - Ok(Field::State) => { - if let Some(state) = state { - val_display_or_store(clipboard, state); - } - } - Ok(Field::PostalCode) => { - if let Some(postal_code) = postal_code { - val_display_or_store(clipboard, postal_code); - } - } - Ok(Field::Country) => { - if let Some(country) = country { - val_display_or_store(clipboard, country); - } - } - Ok(Field::Phone) => { - if let Some(phone) = phone { - val_display_or_store(clipboard, phone); - } - } - Ok(Field::Ssn) => { - if let Some(ssn) = ssn { - val_display_or_store(clipboard, ssn); - } - } - Ok(Field::License) => { - if let Some(license_number) = license_number { - val_display_or_store(clipboard, license_number); - } - } - Ok(Field::Passport) => { - if let Some(passport_number) = passport_number { - val_display_or_store(clipboard, passport_number); - } - } - Ok(Field::Username) => { - if let Some(username) = username { - val_display_or_store(clipboard, username); - } - } - Ok(Field::Notes) => { - if let Some(notes) = &self.notes { - val_display_or_store(clipboard, notes); - } - } - _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); - break; - } - } - } - } - }, - DecryptedData::SecureNote => match field.parse() { - Ok(Field::Notes) => { - self.display_short(desc, clipboard); - } - _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); - break; - } - } - } - } - }, - DecryptedData::SshKey { - fingerprint, - private_key, - .. - } => match field.parse() { - Ok(Field::Fingerprint) => { - if let Some(fingerprint) = fingerprint { - val_display_or_store(clipboard, fingerprint); - } - } - Ok(Field::PublicKey) => { - self.display_short(desc, clipboard); - } - Ok(Field::PrivateKey) => { - if let Some(private_key) = private_key { - val_display_or_store(clipboard, private_key); - } - } - Ok(Field::Notes) => { - if let Some(notes) = &self.notes { - val_display_or_store(clipboard, notes); - } - } - _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); - break; - } - } - } - } - }, - } - } + let name = entry.decrypt_string(&entry.name, &mut dec)?; + let folder = + dec.decrypt_optfield(None::<&rbw::db::Entry>, &entry.folder.as_deref())?; + let notes = entry.decrypt_optstring(&entry.notes, &mut dec)?; - fn display_long(&self, desc: &str, clipboard: bool) { - match &self.data { - DecryptedData::Login { - username, - totp, - uris, - .. - } => { - let mut displayed = self.display_short(desc, clipboard); - displayed |= - display_field("Username", username.as_deref(), clipboard); - displayed |= - display_field("TOTP Secret", totp.as_deref(), clipboard); - - if let Some(uris) = uris { - for uri in uris { - displayed |= - display_field("URI", Some(&uri.uri), clipboard); - let match_type = - uri.match_type.map(|ty| format!("{ty}")); - displayed |= display_field( - "Match type", - match_type.as_deref(), - clipboard, - ); - } - } + let uris = entry + .decrypt_uris(&mut dec)? + .into_iter() + .map(|u| (u.uri, u.match_type)) + .collect(); - for field in &self.fields { - displayed |= display_field( - field.name.as_deref().unwrap_or("(null)"), - Some(field.value.as_deref().unwrap_or("")), - clipboard, - ); + let fields = entry + .decrypt_custom_fields(&mut dec)? + .into_iter() + .filter_map(|f| { + if f.ty == Some(rbw::api::FieldType::Hidden) { + None + } else { + f.value } + }) + .collect(); - if let Some(notes) = &self.notes { - if displayed { - println!(); - } - println!("{notes}"); - } - } - DecryptedData::Card { - cardholder_name, - brand, - exp_month, - exp_year, - code, - .. - } => { - let mut displayed = false; - - displayed |= self.display_short(desc, clipboard); - if let (Some(exp_month), Some(exp_year)) = - (exp_month, exp_year) - { - println!("Expiration: {exp_month}/{exp_year}"); - displayed = true; - } - displayed |= display_field("CVV", code.as_deref(), clipboard); - displayed |= display_field( - "Name", - cardholder_name.as_deref(), - clipboard, - ); - displayed |= - display_field("Brand", brand.as_deref(), clipboard); - - if let Some(notes) = &self.notes { - if displayed { - println!(); - } - println!("{notes}"); - } - } - DecryptedData::Identity { - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - .. - } => { - let mut displayed = self.display_short(desc, clipboard); - - displayed |= - display_field("Address", address1.as_deref(), clipboard); - displayed |= - display_field("Address", address2.as_deref(), clipboard); - displayed |= - display_field("Address", address3.as_deref(), clipboard); - displayed |= - display_field("City", city.as_deref(), clipboard); - displayed |= - display_field("State", state.as_deref(), clipboard); - displayed |= display_field( - "Postcode", - postal_code.as_deref(), - clipboard, - ); - displayed |= - display_field("Country", country.as_deref(), clipboard); - displayed |= - display_field("Phone", phone.as_deref(), clipboard); - displayed |= - display_field("Email", email.as_deref(), clipboard); - displayed |= display_field("SSN", ssn.as_deref(), clipboard); - displayed |= display_field( - "License", - license_number.as_deref(), - clipboard, - ); - displayed |= display_field( - "Passport", - passport_number.as_deref(), - clipboard, - ); - displayed |= - display_field("Username", username.as_deref(), clipboard); - - if let Some(notes) = &self.notes { - if displayed { - println!(); - } - println!("{notes}"); - } - } - DecryptedData::SecureNote => { - self.display_short(desc, clipboard); - } - DecryptedData::SshKey { fingerprint, .. } => { - let mut displayed = self.display_short(desc, clipboard); - displayed |= display_field( - "Fingerprint", - fingerprint.as_deref(), - clipboard, - ); - - for field in &self.fields { - displayed |= display_field( - field.name.as_deref().unwrap_or("(null)"), - Some(field.value.as_deref().unwrap_or("")), - clipboard, - ); - } + let entry_type = (match &entry.data { + rbw::db::EntryData::Login { .. } => "Login", + rbw::db::EntryData::Identity { .. } => "Identity", + rbw::db::EntryData::SshKey { .. } => "SSH Key", + rbw::db::EntryData::SecureNote => "Note", + rbw::db::EntryData::Card { .. } => "Card", + }) + .to_string(); + + Ok(SearchEntry { + id: entry.id.clone(), + entry_type, + folder, + name, + user, + uris, + fields, + notes, + }) + } +} + +fn host_port(url: &url::Url) -> Option { + let host = url.host_str()?; + Some( + url.port() + .map_or_else(|| host.to_string(), |port| format!("{host}:{port}")), + ) +} - if let Some(notes) = &self.notes { - if displayed { - println!(); +fn matches_url( + url: &str, + match_type: Option, + given_url: &url::Url, +) -> bool { + match match_type.unwrap_or(rbw::api::UriMatchType::Domain) { + rbw::api::UriMatchType::Domain | rbw::api::UriMatchType::Host => { + let is_domain = matches!( + match_type.unwrap_or(rbw::api::UriMatchType::Domain), + rbw::api::UriMatchType::Domain + ); + let Some(given_host_port) = host_port(given_url) else { + return false; + }; + if let Ok(self_url) = url::Url::parse(url) { + if let Some(self_host_port) = host_port(&self_url) { + if self_url.scheme() == given_url.scheme() + && (self_host_port == given_host_port + || (is_domain + && given_host_port.ends_with(&format!(".{self_host_port}")))) + { + return true; } - println!("{notes}"); } } + url == given_host_port || (is_domain && given_host_port.ends_with(&format!(".{url}"))) + } + rbw::api::UriMatchType::StartsWith => given_url.to_string().starts_with(url), + rbw::api::UriMatchType::Exact => { + given_url.to_string().trim_end_matches('/') == url.trim_end_matches('/') + } + rbw::api::UriMatchType::RegularExpression => { + regex::Regex::new(url).is_ok_and(|rx| rx.is_match(given_url.as_ref())) } + rbw::api::UriMatchType::Never => false, } +} - /// This implementation mirror the `fn display_fied` method on which field to list - fn display_fields_list(&self) { - match &self.data { - DecryptedData::Login { - username, - password, - totp, - uris, - .. - } => { - if username.is_some() { - println!("{}", Field::Username); - } - if totp.is_some() { - println!("{}", Field::Totp); - } - if uris.is_some() { - println!("{}", Field::Uris); - } - if password.is_some() { - println!("{}", Field::Password); - } - } - DecryptedData::Card { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - .. - } => { - if number.is_some() { - println!("{}", Field::CardNumber); - } - if exp_month.is_some() { - println!("{}", Field::ExpMonth); - } - if exp_year.is_some() { - println!("{}", Field::ExpYear); - } - if code.is_some() { - println!("{}", Field::Cvv); - } - if cardholder_name.is_some() { - println!("{}", Field::Cardholder); - } - if brand.is_some() { - println!("{}", Field::Brand); - } - } - - DecryptedData::Identity { - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - title, - first_name, - middle_name, - last_name, - .. - } => { - if [title, first_name, middle_name, last_name] - .iter() - .any(|f| f.is_some()) - { - // the display_field combines all these fields together. - println!("name"); - } - if email.is_some() { - println!("{}", Field::Email); - } - if [address1, address2, address3].iter().any(|f| f.is_some()) - { - // the display_field combines all these fields together. - println!("address"); - } - if city.is_some() { - println!("{}", Field::City); - } - if state.is_some() { - println!("{}", Field::State); - } - if postal_code.is_some() { - println!("{}", Field::PostalCode); - } - if country.is_some() { - println!("{}", Field::Country); - } - if phone.is_some() { - println!("{}", Field::Phone); - } - if ssn.is_some() { - println!("{}", Field::Ssn); - } - if license_number.is_some() { - println!("{}", Field::License); - } - if passport_number.is_some() { - println!("{}", Field::Passport); - } - if username.is_some() { - println!("{}", Field::Username); - } - } - - DecryptedData::SecureNote => (), // handled at the end - DecryptedData::SshKey { - fingerprint, - public_key, - .. - } => { - if fingerprint.is_some() { - println!("{}", Field::Fingerprint); - } - if public_key.is_some() { - println!("{}", Field::PublicKey); - } - } - } - - if self.notes.is_some() { - println!("{}", Field::Notes); - } - for f in &self.fields { - if let Some(name) = &f.name { - println!("{name}"); - } - } - } - - fn display_json(&self, desc: &str) -> anyhow::Result<()> { - serde_json::to_writer_pretty(std::io::stdout(), &self) - .context(format!("failed to write entry '{desc}' to stdout"))?; - println!(); - - Ok(()) - } -} - -fn val_display_or_store(clipboard: bool, password: &str) -> bool { - if clipboard { - match clipboard_store(password) { - Ok(()) => true, - Err(e) => { - eprintln!("{e}"); - false - } - } - } else { - println!("{password}"); - true - } -} - -#[derive(Debug, Clone, serde::Serialize)] -#[serde(untagged)] -#[cfg_attr(test, derive(Eq, PartialEq))] -enum DecryptedData { - Login { - username: Option, - password: Option, - totp: Option, - uris: Option>, - }, - Card { - cardholder_name: Option, - number: Option, - brand: Option, - exp_month: Option, - exp_year: Option, - code: Option, - }, - Identity { - title: Option, - first_name: Option, - middle_name: Option, - last_name: Option, - address1: Option, - address2: Option, - address3: Option, - city: Option, - state: Option, - postal_code: Option, - country: Option, - phone: Option, - email: Option, - ssn: Option, - license_number: Option, - passport_number: Option, - username: Option, - }, - SecureNote, - SshKey { - public_key: Option, - fingerprint: Option, - private_key: Option, - }, -} - -#[derive(Debug, Clone, serde::Serialize)] -#[cfg_attr(test, derive(Eq, PartialEq))] -struct DecryptedField { - name: Option, - value: Option, - #[serde(serialize_with = "serialize_field_type", rename = "type")] - ty: Option, -} - -#[allow(clippy::trivially_copy_pass_by_ref, clippy::ref_option)] -fn serialize_field_type( - ty: &Option, - serializer: S, -) -> Result -where - S: serde::Serializer, -{ - match ty { - Some(ty) => { - let s = match ty { - rbw::api::FieldType::Text => "text", - rbw::api::FieldType::Hidden => "hidden", - rbw::api::FieldType::Boolean => "boolean", - rbw::api::FieldType::Linked => "linked", - }; - serializer.serialize_some(&Some(s)) - } - None => serializer.serialize_none(), - } -} - -#[derive(Debug, Clone, serde::Serialize)] -#[cfg_attr(test, derive(Eq, PartialEq))] -struct DecryptedHistoryEntry { - last_used_date: String, - password: String, -} - -#[derive(Debug, Clone, serde::Serialize)] -#[cfg_attr(test, derive(Eq, PartialEq))] -struct DecryptedUri { - uri: String, - match_type: Option, -} - -fn matches_url( - url: &str, - match_type: Option, - given_url: &url::Url, -) -> bool { - match match_type.unwrap_or(rbw::api::UriMatchType::Domain) { - rbw::api::UriMatchType::Domain => { - let Some(given_host_port) = host_port(given_url) else { - return false; - }; - if let Ok(self_url) = url::Url::parse(url) { - if let Some(self_host_port) = host_port(&self_url) { - if self_url.scheme() == given_url.scheme() - && (self_host_port == given_host_port - || given_host_port - .ends_with(&format!(".{self_host_port}"))) - { - return true; - } - } - } - url == given_host_port - || given_host_port.ends_with(&format!(".{url}")) - } - rbw::api::UriMatchType::Host => { - let Some(given_host_port) = host_port(given_url) else { - return false; - }; - if let Ok(self_url) = url::Url::parse(url) { - if let Some(self_host_port) = host_port(&self_url) { - if self_url.scheme() == given_url.scheme() - && self_host_port == given_host_port - { - return true; - } - } - } - url == given_host_port - } - rbw::api::UriMatchType::StartsWith => { - given_url.to_string().starts_with(url) - } - rbw::api::UriMatchType::Exact => { - if given_url.path() == "/" { - given_url.to_string().trim_end_matches('/') - == url.trim_end_matches('/') - } else { - given_url.to_string() == url - } - } - rbw::api::UriMatchType::RegularExpression => { - let Ok(rx) = regex::Regex::new(url) else { - return false; - }; - rx.is_match(given_url.as_ref()) - } - rbw::api::UriMatchType::Never => false, - } -} - -fn host_port(url: &url::Url) -> Option { - let host = url.host_str()?; - Some( - url.port().map_or_else( - || host.to_string(), - |port| format!("{host}:{port}"), - ), - ) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ListField { - Id, - Name, - User, - Folder, - Uri, - EntryType, -} +// TODO: This could be a dup of FieldType? +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ListField { + Id, + Name, + User, + Folder, + Uri, + EntryType, +} impl ListField { - fn all() -> Vec { - vec![ + fn all() -> &'static [Self] { + &[ Self::Id, Self::Name, Self::User, @@ -1201,7 +291,7 @@ impl ListField { } } -impl std::convert::TryFrom<&String> for ListField { +impl TryFrom<&String> for ListField { type Error = anyhow::Error; fn try_from(s: &String) -> anyhow::Result { @@ -1216,17 +306,6 @@ impl std::convert::TryFrom<&String> for ListField { } } -const HELP_PW: &str = r" -# The first line of this file will be the password, and the remainder of the -# file (after any blank lines after the password) will be stored as a note. -# Lines with leading # will be ignored. -"; - -const HELP_NOTES: &str = r" -# The content of this file will be stored as a note. -# Lines with leading # will be ignored. -"; - pub fn config_show() -> anyhow::Result<()> { let config = rbw::config::Config::load()?; serde_json::to_writer_pretty(std::io::stdout(), &config) @@ -1236,9 +315,9 @@ pub fn config_show() -> anyhow::Result<()> { Ok(()) } +// TODO: Make this a Config method pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { - let mut config = rbw::config::Config::load() - .unwrap_or_else(|_| rbw::config::Config::new()); + let mut config = rbw::config::Config::load().unwrap_or_else(|_| rbw::config::Config::new()); match key { "email" => config.email = Some(value.to_string()), "sso_id" => config.sso_id = Some(value.to_string()), @@ -1249,8 +328,7 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { config.notifications_url = Some(value.to_string()); } "client_cert_path" => { - config.client_cert_path = - Some(std::path::PathBuf::from(value.to_string())); + config.client_cert_path = Some(PathBuf::from(value.to_string())); } "lock_timeout" => { let timeout = value @@ -1269,6 +347,7 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { config.sync_interval = interval; } "pinentry" => config.pinentry = value.to_string(), + "confirm_ssh" => config.confirm_ssh = Some(value == "true"), _ => return Err(anyhow::anyhow!("invalid config key: {key}")), } config.save()?; @@ -1278,14 +357,11 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { // be running (since this may be the user running `rbw config set // base_url` as the first operation), and stop_agent() already handles the // agent not running case gracefully. - stop_agent()?; - - Ok(()) + stop_agent() } pub fn config_unset(key: &str) -> anyhow::Result<()> { - let mut config = rbw::config::Config::load() - .unwrap_or_else(|_| rbw::config::Config::new()); + let mut config = rbw::config::Config::load().unwrap_or_else(|_| rbw::config::Config::new()); match key { "email" => config.email = None, "sso_id" => config.sso_id = None, @@ -1298,6 +374,7 @@ pub fn config_unset(key: &str) -> anyhow::Result<()> { config.lock_timeout = rbw::config::default_lock_timeout(); } "pinentry" => config.pinentry = rbw::config::default_pinentry(), + "confirm_ssh" => config.confirm_ssh = rbw::config::default_confirm_ssh(), _ => return Err(anyhow::anyhow!("invalid config key: {key}")), } config.save()?; @@ -1307,124 +384,242 @@ pub fn config_unset(key: &str) -> anyhow::Result<()> { // be running (since this may be the user running `rbw config set // base_url` as the first operation), and stop_agent() already handles the // agent not running case gracefully. - stop_agent()?; - - Ok(()) + stop_agent() } fn clipboard_store(val: &str) -> anyhow::Result<()> { ensure_agent()?; - crate::actions::clipboard_store(val)?; - - Ok(()) + crate::actions::clipboard_store(val) } pub fn register() -> anyhow::Result<()> { ensure_agent()?; - crate::actions::register()?; - - Ok(()) + crate::actions::register() } pub fn login() -> anyhow::Result<()> { ensure_agent()?; - crate::actions::login()?; - - Ok(()) + crate::actions::login() } pub fn unlock() -> anyhow::Result<()> { ensure_agent()?; crate::actions::login()?; - crate::actions::unlock()?; - - Ok(()) + crate::actions::unlock() } pub fn unlocked() -> anyhow::Result<()> { // not ensure_agent, because we don't want `rbw unlocked` to start the // agent if it's not running let _ = check_agent_version(); - crate::actions::unlocked()?; - - Ok(()) + crate::actions::unlocked() } pub fn sync() -> anyhow::Result<()> { ensure_agent()?; crate::actions::login()?; - crate::actions::sync()?; - - Ok(()) + crate::actions::sync() } -pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { - let fields: Vec = if raw { - ListField::all() - } else { - fields - .iter() - .map(std::convert::TryFrom::try_from) - .collect::>()? - }; - - unlock()?; +fn find_entry( + db: &rbw::db::Db, + mut needle: Needle, + username: Option<&str>, + folder: Option<&str>, + ignore_case: bool, +) -> anyhow::Result> { + if let Needle::Uuid(uuid, s) = needle { + for cipher in &db.entries { + if uuid::Uuid::parse_str(&cipher.id) == Ok(uuid) { + return Ok(cipher.clone()); + } + } + needle = Needle::Name(s); + } - let db = load_db()?; - let mut entries: Vec = db + let ciphers: Vec<(rbw::db::Entry, SearchEntry)> = db .entries .iter() - .map(|entry| decrypt_list_cipher(entry, &fields)) + .map(|entry| entry.try_into().map(|decrypted| (entry.clone(), decrypted))) .collect::>()?; - entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); - print_entry_list(&entries, &fields, raw)?; + let (entry, _) = find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; - Ok(()) + Ok(entry) } -#[allow(clippy::fn_params_excessive_bools)] -pub fn get( - needle: Needle, - user: Option<&str>, +fn find_entry_raw( + entries: &[(rbw::db::Entry, SearchEntry)], + needle: &Needle, + username: Option<&str>, folder: Option<&str>, + ignore_case: bool, +) -> anyhow::Result<(rbw::db::Entry, SearchEntry)> { + let mut matches: Vec<(rbw::db::Entry, SearchEntry)> = vec![]; + + let find_matches = |strict_username, strict_folder, exact| { + entries + .iter() + .filter(|&(_, decrypted_cipher)| { + decrypted_cipher.matches( + needle, + username, + folder, + ignore_case, + strict_username, + strict_folder, + exact, + ) + }) + .cloned() + .collect() + }; + + for exact in [true, false] { + matches = find_matches(true, true, exact); + if matches.len() == 1 { + return Ok(matches[0].clone()); + } + + let strict_folder_matches = find_matches(false, true, exact); + let strict_username_matches = find_matches(true, false, exact); + if strict_folder_matches.len() == 1 && strict_username_matches.len() != 1 { + return Ok(strict_folder_matches[0].clone()); + } else if strict_folder_matches.len() != 1 && strict_username_matches.len() == 1 { + return Ok(strict_username_matches[0].clone()); + } + + matches = find_matches(false, false, exact); + if matches.len() == 1 { + return Ok(matches[0].clone()); + } + } + + if matches.is_empty() { + Err(anyhow::anyhow!("no entry found")) + } else { + let entries: Vec = matches + .iter() + .map(|(_, decrypted)| decrypted.display_name()) + .collect(); + let entries = entries.join(", "); + Err(anyhow::anyhow!("multiple entries found: {entries}")) + } +} + +pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str) { + let fields = entry.get_field(&field.to_lowercase(), generate_totp); + if fields.is_empty() { + // TODO: This is not 100% compatible text output with the project before refactor. + eprintln!("entry for '{desc}' had no {field} field"); + } else { + fields.iter().for_each(|f| { + println!("{f}"); + }); + } +} + +pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str) -> bool { + let short = entry.get_short(); + let Some(short) = short else { + // Would be cool if self.data had a method named main_field_name :D + eprintln!( + "entry for '{desc}' had no {}", + match entry.data { + EntryData::Login { .. } => "password", + EntryData::Card { .. } => "card number", + EntryData::Identity { .. } => "name", + EntryData::SecureNote => "notes", + EntryData::SshKey { .. } => "public key", + } + ); + return false; + }; + + println!("{short}"); + true +} + +pub async fn get( + FindArgs { + needle, + user, + folder, + ignorecase, + }: FindArgs, field: Option<&str>, full: bool, raw: bool, clipboard: bool, - ignore_case: bool, list_fields: bool, ) -> anyhow::Result<()> { unlock()?; - let db = load_db()?; + let db = load_db().await?; + let mut dec = RemoteDecrypter {}; let desc = format!( "{}{}", - user.map_or_else(String::new, |s| format!("{s}@")), + user.as_ref().map_or_else(String::new, |s| format!("{s}@")), needle ); - let (_, decrypted) = - find_entry(&db, needle, user, folder, ignore_case) - .with_context(|| format!("couldn't find entry for '{desc}'"))?; + let entry = find_entry(&db, needle, user.as_deref(), folder.as_deref(), ignorecase) + .with_context(|| format!("couldn't find entry for '{desc}'"))?; + + let decrypted = entry.decrypt(&mut dec)?; + if list_fields { - decrypted.display_fields_list(); + decrypted + .get_fields_list() + .iter() + .for_each(|field| println!("{field}")); } else if raw { - decrypted.display_json(&desc)?; - } else if full { - decrypted.display_long(&desc, clipboard); - } else if let Some(field) = field { - decrypted.display_field(&desc, field, clipboard); + serde_json::to_writer_pretty(std::io::stdout(), &decrypted) + .context(format!("failed to write entry '{desc}' to stdout"))?; + println!(); } else { - decrypted.display_short(&desc, clipboard); + let short = decrypted.get_short(); + + if clipboard { + if let Some(field) = &field { + let value = decrypted.get_field(field, generate_totp); + if let Err(e) = clipboard_store(&value.join(" ")) { + eprintln!("{e}"); + } + } else if let Some(short) = &short { + if let Err(e) = clipboard_store(short) { + eprintln!("{e}"); + } + } + } + + if full { + // NOTE: In the previous version this printed "password", etc, the name of the "short" + // field. + if short.is_none() { + eprintln!("entry for '{desc}' had no default field"); + } + + // NOTE: This printing is 99% backwards compatible, but the previous version was putting + // EVERY field in the clipboard sequentially, leaving only the last at the end of course. + // This behavior is unwanted, unnecessary and makes the code messy and for these reason + // it has been removed. Now when specifying --clipboard, only the "short" field or the + // --field value gets copied. + print!("{decrypted}"); + } else if let Some(field) = field { + display_entry_field(&decrypted, &desc, field); + } else { + display_entry_short(&decrypted, &desc); + } } Ok(()) } +/// Used in "search" and "list" fn print_entry_list( - entries: &[DecryptedListCipher], + entries: &[SearchEntry], fields: &[ListField], raw: bool, ) -> anyhow::Result<()> { @@ -1434,22 +629,13 @@ fn print_entry_list( println!(); } else { for entry in entries { - let values: Vec = fields + let values: Vec<&str> = fields .iter() .map(|field| match field { - ListField::Id => entry.id.clone(), - ListField::Name => entry.name.as_ref().map_or_else( - String::new, - std::string::ToString::to_string, - ), - ListField::User => entry.user.as_ref().map_or_else( - String::new, - std::string::ToString::to_string, - ), - ListField::Folder => entry.folder.as_ref().map_or_else( - String::new, - std::string::ToString::to_string, - ), + ListField::Id => &entry.id, + ListField::Name => &entry.name, + ListField::User => entry.user.as_deref().unwrap_or(""), + ListField::Folder => entry.folder.as_deref().unwrap_or(""), ListField::Uri => { // "uri" is not listed in the TryFrom // implementation, so there's no way to try to @@ -1458,21 +644,14 @@ fn print_entry_list( // string) unreachable!() } - ListField::EntryType => { - entry.entry_type.as_ref().map_or_else( - String::new, - std::string::ToString::to_string, - ) - } + ListField::EntryType => &entry.entry_type, }) .collect(); // write to stdout but don't panic when pipe get's closed // this happens when piping stdout in a shell match writeln!(&mut std::io::stdout(), "{}", values.join("\t")) { - Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => { - Ok(()) - } + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), res => res, }?; } @@ -1481,72 +660,83 @@ fn print_entry_list( Ok(()) } -pub fn search( +pub async fn search( term: &str, fields: &[String], folder: Option<&str>, raw: bool, ) -> anyhow::Result<()> { let fields: Vec = if raw { - ListField::all() + ListField::all().to_vec() } else { fields .iter() - .map(std::convert::TryFrom::try_from) + .map(TryFrom::try_from) .collect::>()? }; unlock()?; - let db = load_db()?; + let db = load_db().await?; - let mut entries: Vec = db + let mut entries: Vec = db .entries .iter() - .map(decrypt_search_cipher) + .map(TryInto::try_into) .filter(|entry| { entry .as_ref() - .map(|entry| entry.search_match(term, folder)) + .map(|entry: &SearchEntry| entry.search_match(term, folder)) .unwrap_or(true) }) - .map(|entry| entry.map(std::convert::Into::into)) .collect::>()?; + entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); - print_entry_list(&entries, &fields, raw)?; + print_entry_list(&entries, &fields, raw) +} - Ok(()) +pub async fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { + search("", fields, None, raw).await } -pub fn code( - needle: Needle, - user: Option<&str>, - folder: Option<&str>, +pub async fn code( + FindArgs { + needle, + user, + folder, + ignorecase, + }: FindArgs, clipboard: bool, - ignore_case: bool, ) -> anyhow::Result<()> { unlock()?; - let db = load_db()?; + let db = load_db().await?; + let mut dec = RemoteDecrypter {}; let desc = format!( "{}{}", - user.map_or_else(String::new, |s| format!("{s}@")), + user.as_ref().map_or_else(String::new, |s| format!("{s}@")), needle ); - let (_, decrypted) = - find_entry(&db, needle, user, folder, ignore_case) - .with_context(|| format!("couldn't find entry for '{desc}'"))?; + let entry = find_entry(&db, needle, user.as_deref(), folder.as_deref(), ignorecase) + .with_context(|| format!("couldn't find entry for '{desc}'"))?; + + if let EntryData::Login { totp, .. } = &entry.data { + let totp = entry.decrypt_optstring(totp, &mut dec)?; - if let DecryptedData::Login { totp, .. } = decrypted.data { if let Some(totp) = totp { - val_display_or_store(clipboard, &generate_totp(&totp)?); + let code = generate_totp(&totp)?; + if clipboard { + if let Err(e) = clipboard_store(&code) { + eprintln!("{e}"); + } + } else { + println!("{code}"); + } } else { - return Err(anyhow::anyhow!( - "entry does not contain a totp secret" - )); + return Err(anyhow::anyhow!("entry does not contain a totp secret")); } } else { return Err(anyhow::anyhow!("not a login entry")); @@ -1555,86 +745,143 @@ pub fn code( Ok(()) } -pub fn add( +async fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Result { + let enc: &mut dyn Encrypter<()> = &mut RemoteEncrypter {}; // fat ptr trick + let dec: &mut dyn Decrypter<()> = &mut RemoteDecrypter {}; + + let (new_access_token, new_refresh_token, folders) = rbw::actions::list_folders( + db.access_token.as_ref().unwrap(), + db.refresh_token.as_ref().unwrap(), + ) + .await?; + + update_token(db, new_access_token, new_refresh_token).await?; + + let folders: Vec<(String, String)> = folders + .into_iter() + .map(|(id, name)| Ok((id, dec.decrypt_field(None, &name)?))) + .collect::>()?; + + let folder_id = folders + .into_iter() + .find_map(|(id, name)| if name == folder { Some(id) } else { None }); + + let folder_id = if let Some(folder_id) = folder_id { + folder_id + } else { + let (new_access_token, new_refresh_token, id) = rbw::actions::create_folder( + db.access_token.as_ref().unwrap(), + db.refresh_token.as_ref().unwrap(), + &enc.encrypt_field(None, folder)?, + ) + .await?; + + update_token(db, new_access_token, new_refresh_token).await?; + + id + }; + + Ok(folder_id) +} + +fn parse_editor(contents: &str) -> (Option, Option) { + let mut lines = contents.lines(); + + let password = lines.next().map(ToString::to_string); + + let mut notes: String = lines + .skip_while(|line| line.is_empty()) + .filter(|line| !line.starts_with('#')) + .collect::>() + .join("\n"); + + if notes.ends_with("\n") { + notes.pop(); + } + + let notes = if notes.is_empty() { None } else { Some(notes) }; + + (password, notes) +} + +async fn update_token( + db: &mut rbw::db::Db, + new_access_token: Option, + new_refresh_token: Option, +) -> anyhow::Result<()> { + let a = db.update_access_token(new_access_token); + let b = db.update_refresh_token(new_refresh_token); + if a || b { + save_db(db).await?; + } + + Ok(()) +} + +const HELP_PW: &str = r" +# The first line of this file will be the password, and the remainder of the +# file (after any blank lines after the password) will be stored as a note. +# Lines with leading # will be ignored. +"; + +const HELP_NOTES: &str = r" +# The content of this file will be stored as a note. +# Lines with leading # will be ignored. +"; + +pub async fn add( name: &str, username: Option<&str>, uris: &[(String, Option)], folder: Option<&str>, + password: Option<&str>, ) -> anyhow::Result<()> { + let enc: &mut dyn Encrypter<()> = &mut RemoteEncrypter {}; // fat ptr trick + unlock()?; - let mut db = load_db()?; + let mut db = load_db().await?; // unwrap is safe here because the call to unlock above is guaranteed to // populate these or error - let mut access_token = db.access_token.as_ref().unwrap().clone(); - let refresh_token = db.refresh_token.as_ref().unwrap(); - let name = crate::actions::encrypt(name, None)?; + let name = enc.encrypt_field(None, name)?; let username = username - .map(|username| crate::actions::encrypt(username, None)) + .map(|username| enc.encrypt_field(None, username)) .transpose()?; - let contents = rbw::edit::edit("", HELP_PW)?; + let (password, notes) = match password { + Some(password) => (Some(password.to_string()), None), + None => { + let contents = rbw::edit::edit("", HELP_PW)?; + parse_editor(&contents) + } + }; - let (password, notes) = parse_editor(&contents); let password = password - .map(|password| crate::actions::encrypt(&password, None)) + .map(|password| enc.encrypt_field(None, &password)) .transpose()?; let notes = notes - .map(|notes| crate::actions::encrypt(¬es, None)) + .map(|notes| enc.encrypt_field(None, ¬es)) .transpose()?; let uris: Vec<_> = uris .iter() .map(|uri| { Ok(rbw::db::Uri { - uri: crate::actions::encrypt(&uri.0, None)?, + uri: enc.encrypt_field(None, &uri.0)?, match_type: uri.1, }) }) .collect::>()?; - let mut folder_id = None; - if let Some(folder_name) = folder { - let (new_access_token, folders) = - rbw::actions::list_folders(&access_token, refresh_token)?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } - - let folders: Vec<(String, String)> = folders - .iter() - .cloned() - .map(|(id, name)| { - Ok((id, crate::actions::decrypt(&name, None, None)?)) - }) - .collect::>()?; - - for (id, name) in folders { - if name == folder_name { - folder_id = Some(id); - } - } - if folder_id.is_none() { - let (new_access_token, id) = rbw::actions::create_folder( - &access_token, - refresh_token, - &crate::actions::encrypt(folder_name, None)?, - )?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } - folder_id = Some(id); - } - } + let folder_id = match folder { + Some(folder) => Some(find_or_create_folder(&mut db, folder).await?), + None => None, + }; - if let (Some(access_token), ()) = rbw::actions::add( - &access_token, - refresh_token, + let (new_token, new_refresh_token, ()) = rbw::actions::add( + db.access_token.as_ref().unwrap(), + db.refresh_token.as_ref().unwrap(), &name, &rbw::db::EntryData::Login { username, @@ -1644,17 +891,15 @@ pub fn add( }, notes.as_deref(), folder_id.as_deref(), - )? { - db.access_token = Some(access_token); - save_db(&db)?; - } + ) + .await?; - crate::actions::sync()?; + update_token(&mut db, new_token, new_refresh_token).await?; - Ok(()) + crate::actions::sync() } -pub fn generate( +pub async fn generate( name: Option<&str>, username: Option<&str>, uris: &[(String, Option)], @@ -1665,268 +910,140 @@ pub fn generate( let password = rbw::pwgen::pwgen(ty, len); println!("{password}"); - if let Some(name) = name { - unlock()?; - - let mut db = load_db()?; - // unwrap is safe here because the call to unlock above is guaranteed - // to populate these or error - let mut access_token = db.access_token.as_ref().unwrap().clone(); - let refresh_token = db.refresh_token.as_ref().unwrap(); - - let name = crate::actions::encrypt(name, None)?; - let username = username - .map(|username| crate::actions::encrypt(username, None)) - .transpose()?; - let password = crate::actions::encrypt(&password, None)?; - let uris: Vec<_> = uris - .iter() - .map(|uri| { - Ok(rbw::db::Uri { - uri: crate::actions::encrypt(&uri.0, None)?, - match_type: uri.1, - }) - }) - .collect::>()?; - - let mut folder_id = None; - if let Some(folder_name) = folder { - let (new_access_token, folders) = - rbw::actions::list_folders(&access_token, refresh_token)?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } - - let folders: Vec<(String, String)> = folders - .iter() - .cloned() - .map(|(id, name)| { - Ok((id, crate::actions::decrypt(&name, None, None)?)) - }) - .collect::>()?; - - for (id, name) in folders { - if name == folder_name { - folder_id = Some(id); - } - } - if folder_id.is_none() { - let (new_access_token, id) = rbw::actions::create_folder( - &access_token, - refresh_token, - &crate::actions::encrypt(folder_name, None)?, - )?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } - folder_id = Some(id); - } - } - - if let (Some(access_token), ()) = rbw::actions::add( - &access_token, - refresh_token, - &name, - &rbw::db::EntryData::Login { - username, - password: Some(password), - uris, - totp: None, - }, - None, - folder_id.as_deref(), - )? { - db.access_token = Some(access_token); - save_db(&db)?; - } - - crate::actions::sync()?; + match name { + Some(name) => add(name, username, uris, folder, Some(&password)).await, + None => Ok(()), } - - Ok(()) } -pub fn edit( - name: Needle, - username: Option<&str>, - folder: Option<&str>, - ignore_case: bool, +pub async fn edit( + FindArgs { + needle, + user, + folder, + ignorecase, + }: FindArgs, ) -> anyhow::Result<()> { unlock()?; - let mut db = load_db()?; - let access_token = db.access_token.as_ref().unwrap(); - let refresh_token = db.refresh_token.as_ref().unwrap(); + let mut db = load_db().await?; + + let mut enc = RemoteEncrypter {}; + let mut dec = RemoteDecrypter {}; let desc = format!( "{}{}", - username.map_or_else(String::new, |s| format!("{s}@")), - name + user.as_ref().map_or_else(String::new, |s| format!("{s}@")), + needle ); - let (entry, decrypted) = - find_entry(&db, name, username, folder, ignore_case) - .with_context(|| format!("couldn't find entry for '{desc}'"))?; + let mut entry = find_entry(&db, needle, user.as_deref(), folder.as_deref(), ignorecase) + .with_context(|| format!("couldn't find entry for '{desc}'"))?; - let (data, fields, notes, history) = match &decrypted.data { - DecryptedData::Login { password, .. } => { - let mut contents = - format!("{}\n", password.as_deref().unwrap_or("")); - if let Some(notes) = decrypted.notes { - write!(contents, "\n{notes}\n").unwrap(); - } + let dec_notes = entry + .decrypt_optstring(&entry.notes, &mut dec)? + .map_or_else(String::new, |n| format!("\n{n}\n")); - let contents = rbw::edit::edit(&contents, HELP_PW)?; + // NOTE: Editing, previously, was limited to Login and SecureNote types. Now it's not limited + // anymore. This behavior is not 100% backwards compatible, but it's hardly noticeable + let (contents, help) = if let EntryData::Login { password, .. } = &entry.data { + let dec_password = entry + .decrypt_optstring(password, &mut dec)? + .unwrap_or_else(String::new); - let (password, notes) = parse_editor(&contents); - let password = password - .map(|password| { - crate::actions::encrypt( - &password, - entry.org_id.as_deref(), - ) - }) - .transpose()?; - let notes = notes - .map(|notes| { - crate::actions::encrypt(¬es, entry.org_id.as_deref()) - }) - .transpose()?; - let mut history = entry.history.clone(); - let rbw::db::EntryData::Login { - username: entry_username, - password: entry_password, - uris: entry_uris, - totp: entry_totp, - } = &entry.data - else { - unreachable!(); - }; + (format!("{dec_password}\n{dec_notes}"), HELP_PW) + } else { + (dec_notes, HELP_NOTES) + }; - if let Some(prev_password) = entry_password.clone() { - let new_history_entry = rbw::db::HistoryEntry { - last_used_date: format!( - "{}", - humantime::format_rfc3339( - std::time::SystemTime::now() - ) - ), - password: prev_password, - }; - history.insert(0, new_history_entry); - } + let (dec_password, dec_notes) = parse_editor(&rbw::edit::edit(&contents, help)?); - let data = rbw::db::EntryData::Login { - username: entry_username.clone(), - password, - uris: entry_uris.clone(), - totp: entry_totp.clone(), - }; - (data, entry.fields, notes, history) - } - DecryptedData::SecureNote => { - let data = rbw::db::EntryData::SecureNote {}; + let new_enc_password = entry.encrypt_optstring(&dec_password, &mut enc)?; - let editor_content = decrypted.notes.map_or_else( - || "\n".to_string(), - |notes| format!("{notes}\n"), + if let EntryData::Login { password, .. } = &mut entry.data { + if let Some(prev_password) = password { + entry.history.insert( + 0, + rbw::db::HistoryEntry { + last_used_date: format!("{}", humantime::format_rfc3339(SystemTime::now())), + password: prev_password.clone(), + }, ); - let contents = rbw::edit::edit(&editor_content, HELP_NOTES)?; + } - // prepend blank line to be parsed as pw by `parse_editor` - let (_, notes) = parse_editor(&format!("\n{contents}\n")); + password.clone_from(&new_enc_password); + } - let notes = notes - .map(|notes| { - crate::actions::encrypt(¬es, entry.org_id.as_deref()) - }) - .transpose()?; + entry.notes = entry.encrypt_optstring(&dec_notes, &mut enc)?; - (data, entry.fields, notes, entry.history) - } - _ => { - return Err(anyhow::anyhow!( - "modifications are only supported for login and note entries" - )); - } - }; + let (new_token, new_refresh_token, ()) = rbw::actions::edit( + db.access_token.as_ref().unwrap(), + db.refresh_token.as_ref().unwrap(), + &entry, + ) + .await?; - if let (Some(access_token), ()) = rbw::actions::edit( - access_token, - refresh_token, - &entry.id, - entry.org_id.as_deref(), - &entry.name, - &data, - &fields, - notes.as_deref(), - entry.folder_id.as_deref(), - &history, - )? { - db.access_token = Some(access_token); - save_db(&db)?; - } + update_token(&mut db, new_token, new_refresh_token).await?; - crate::actions::sync()?; - Ok(()) + crate::actions::sync() } -pub fn remove( - name: Needle, - username: Option<&str>, - folder: Option<&str>, - ignore_case: bool, +pub async fn remove( + FindArgs { + needle, + user, + folder, + ignorecase, + }: FindArgs, ) -> anyhow::Result<()> { unlock()?; - let mut db = load_db()?; - let access_token = db.access_token.as_ref().unwrap(); - let refresh_token = db.refresh_token.as_ref().unwrap(); + let mut db = load_db().await?; let desc = format!( "{}{}", - username.map_or_else(String::new, |s| format!("{s}@")), - name + user.as_ref().map_or_else(String::new, |s| format!("{s}@")), + needle ); - let (entry, _) = find_entry(&db, name, username, folder, ignore_case) + let entry = find_entry(&db, needle, user.as_deref(), folder.as_deref(), ignorecase) .with_context(|| format!("couldn't find entry for '{desc}'"))?; - if let (Some(access_token), ()) = - rbw::actions::remove(access_token, refresh_token, &entry.id)? - { - db.access_token = Some(access_token); - save_db(&db)?; - } + let (new_access_token, new_refresh_token, ()) = rbw::actions::remove( + db.access_token.as_ref().unwrap(), + db.refresh_token.as_ref().unwrap(), + &entry.id, + ) + .await?; - crate::actions::sync()?; + update_token(&mut db, new_access_token, new_refresh_token).await?; - Ok(()) + crate::actions::sync() } -pub fn history( - name: Needle, - username: Option<&str>, - folder: Option<&str>, - ignore_case: bool, +pub async fn history( + FindArgs { + needle: name, + user, + folder, + ignorecase, + }: FindArgs, ) -> anyhow::Result<()> { unlock()?; - let db = load_db()?; + let db = load_db().await?; + let mut dec = RemoteDecrypter {}; let desc = format!( "{}{}", - username.map_or_else(String::new, |s| format!("{s}@")), + user.as_ref().map_or_else(String::new, |s| format!("{s}@")), name ); - let (_, decrypted) = find_entry(&db, name, username, folder, ignore_case) + let entry = find_entry(&db, name, user.as_deref(), folder.as_deref(), ignorecase) .with_context(|| format!("couldn't find entry for '{desc}'"))?; - for history in decrypted.history { + + for history in entry.decrypt_history(&mut dec)? { println!("{}: {}", history.last_used_date, history.password); } @@ -1935,23 +1052,17 @@ pub fn history( pub fn lock() -> anyhow::Result<()> { ensure_agent()?; - crate::actions::lock()?; - - Ok(()) + crate::actions::lock() } pub fn purge() -> anyhow::Result<()> { stop_agent()?; - remove_db()?; - - Ok(()) + remove_db() } pub fn stop_agent() -> anyhow::Result<()> { - crate::actions::quit()?; - - Ok(()) + crate::actions::quit() } fn ensure_agent() -> anyhow::Result<()> { @@ -1960,8 +1071,7 @@ fn ensure_agent() -> anyhow::Result<()> { return Ok(()); } run_agent()?; - check_agent_version()?; - Ok(()) + check_agent_version() } fn run_agent() -> anyhow::Result<()> { @@ -1975,9 +1085,7 @@ fn run_agent() -> anyhow::Result<()> { if !status.success() { if let Some(code) = status.code() { if code != 23 { - return Err(anyhow::anyhow!( - "failed to run rbw-agent: {status}" - )); + return Err(anyhow::anyhow!("failed to run rbw-agent: {status}")); } } } @@ -1985,6 +1093,16 @@ fn run_agent() -> anyhow::Result<()> { Ok(()) } +const MISSING_CONFIG_HELP: &str = + "Before using rbw, you must configure the email address you would like to \ + use to log in to the server by running:\n\n \ + rbw config set email \n\n\ + Additionally, if you are using a self-hosted installation, you should \ + run:\n\n \ + rbw config set base_url \n\n\ + and, if your server has a non-default identity url:\n\n \ + rbw config set identity_url \n"; + fn check_config() -> anyhow::Result<()> { rbw::config::Config::validate().map_err(|e| { log::error!("{MISSING_CONFIG_HELP}"); @@ -1994,697 +1112,54 @@ fn check_config() -> anyhow::Result<()> { fn check_agent_version() -> anyhow::Result<()> { let client_version = rbw::protocol::VERSION; - let agent_version = version_or_quit()?; - if agent_version != client_version { - crate::actions::quit()?; - return Err(anyhow::anyhow!( - "client protocol version is {client_version} but agent protocol version is {agent_version}" - )); - } - Ok(()) -} - -fn version_or_quit() -> anyhow::Result { - crate::actions::version().inspect_err(|_| { - let _ = crate::actions::quit(); - }) -} - -fn find_entry( - db: &rbw::db::Db, - mut needle: Needle, - username: Option<&str>, - folder: Option<&str>, - ignore_case: bool, -) -> anyhow::Result<(rbw::db::Entry, DecryptedCipher)> { - if let Needle::Uuid(uuid, s) = needle { - for cipher in &db.entries { - if uuid::Uuid::parse_str(&cipher.id) == Ok(uuid) { - return Ok((cipher.clone(), decrypt_cipher(cipher)?)); - } - } - needle = Needle::Name(s); - } - - let ciphers: Vec<(rbw::db::Entry, DecryptedSearchCipher)> = db - .entries - .iter() - .map(|entry| { - decrypt_search_cipher(entry) - .map(|decrypted| (entry.clone(), decrypted)) - }) - .collect::>()?; - let (entry, _) = - find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; - let decrypted_entry = decrypt_cipher(&entry)?; - Ok((entry, decrypted_entry)) -} - -fn find_entry_raw( - entries: &[(rbw::db::Entry, DecryptedSearchCipher)], - needle: &Needle, - username: Option<&str>, - folder: Option<&str>, - ignore_case: bool, -) -> anyhow::Result<(rbw::db::Entry, DecryptedSearchCipher)> { - let mut matches: Vec<(rbw::db::Entry, DecryptedSearchCipher)> = vec![]; - - let find_matches = |strict_username, strict_folder, exact| { - entries - .iter() - .filter(|&(_, decrypted_cipher)| { - decrypted_cipher.matches( - needle, - username, - folder, - ignore_case, - strict_username, - strict_folder, - exact, - ) - }) - .cloned() - .collect() - }; - - for exact in [true, false] { - matches = find_matches(true, true, exact); - if matches.len() == 1 { - return Ok(matches[0].clone()); - } - - let strict_folder_matches = find_matches(false, true, exact); - let strict_username_matches = find_matches(true, false, exact); - if strict_folder_matches.len() == 1 - && strict_username_matches.len() != 1 - { - return Ok(strict_folder_matches[0].clone()); - } else if strict_folder_matches.len() != 1 - && strict_username_matches.len() == 1 - { - return Ok(strict_username_matches[0].clone()); - } - - matches = find_matches(false, false, exact); - if matches.len() == 1 { - return Ok(matches[0].clone()); - } - } - - if matches.is_empty() { - Err(anyhow::anyhow!("no entry found")) - } else { - let entries: Vec = matches - .iter() - .map(|(_, decrypted)| decrypted.display_name()) - .collect(); - let entries = entries.join(", "); - Err(anyhow::anyhow!("multiple entries found: {entries}")) - } -} - -fn decrypt_field( - name: Field, - field: Option<&str>, - entry_key: Option<&str>, - org_id: Option<&str>, -) -> Option { - let field = field - .as_ref() - .map(|field| crate::actions::decrypt(field, entry_key, org_id)) - .transpose(); - match field { - Ok(field) => field, - Err(e) => { - log::warn!("failed to decrypt {name}: {e}"); - None - } - } -} - -fn decrypt_list_cipher( - entry: &rbw::db::Entry, - fields: &[ListField], -) -> anyhow::Result { - let id = entry.id.clone(); - let name = if fields.contains(&ListField::Name) { - Some(crate::actions::decrypt( - &entry.name, - entry.key.as_deref(), - entry.org_id.as_deref(), - )?) - } else { - None - }; - let user = if fields.contains(&ListField::User) { - match &entry.data { - rbw::db::EntryData::Login { username, .. } => decrypt_field( - Field::Username, - username.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - _ => None, - } - } else { - None - }; - let folder = if fields.contains(&ListField::Folder) { - // folder name should always be decrypted with the local key because - // folders are local to a specific user's vault, not the organization - entry - .folder - .as_ref() - .map(|folder| crate::actions::decrypt(folder, None, None)) - .transpose()? - } else { - None - }; - let uris = if fields.contains(&ListField::Uri) { - match &entry.data { - rbw::db::EntryData::Login { uris, .. } => Some( - uris.iter() - .filter_map(|s| { - decrypt_field( - Field::Uris, - Some(&s.uri), - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - }) - .collect(), - ), - _ => None, - } - } else { - None - }; - let entry_type = fields - .contains(&ListField::EntryType) - .then_some(match &entry.data { - rbw::db::EntryData::Login { .. } => "Login", - rbw::db::EntryData::Identity { .. } => "Identity", - rbw::db::EntryData::SshKey { .. } => "SSH Key", - rbw::db::EntryData::SecureNote => "Note", - rbw::db::EntryData::Card { .. } => "Card", - }) - .map(str::to_string); - - Ok(DecryptedListCipher { - id, - name, - user, - folder, - uris, - entry_type, - }) -} - -fn decrypt_search_cipher( - entry: &rbw::db::Entry, -) -> anyhow::Result { - let id = entry.id.clone(); - let name = crate::actions::decrypt( - &entry.name, - entry.key.as_deref(), - entry.org_id.as_deref(), - )?; - let user = match &entry.data { - rbw::db::EntryData::Login { username, .. } => decrypt_field( - Field::Username, - username.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - _ => None, - }; - // folder name should always be decrypted with the local key because - // folders are local to a specific user's vault, not the organization - let folder = entry - .folder - .as_ref() - .map(|folder| crate::actions::decrypt(folder, None, None)) - .transpose()?; - let notes = entry - .notes - .as_ref() - .map(|notes| { - crate::actions::decrypt( - notes, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - }) - .transpose(); - let uris = if let rbw::db::EntryData::Login { uris, .. } = &entry.data { - uris.iter() - .filter_map(|s| { - decrypt_field( - Field::Uris, - Some(&s.uri), - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .map(|uri| (uri, s.match_type)) - }) - .collect() - } else { - vec![] - }; - let fields = entry - .fields - .iter() - .filter_map(|field| { - if field.ty == Some(rbw::api::FieldType::Hidden) { - None - } else { - field.value.as_ref() - } - }) - .map(|value| { - crate::actions::decrypt( - value, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - }) - .collect::>()?; - let notes = match notes { - Ok(notes) => notes, - Err(e) => { - log::warn!("failed to decrypt notes: {e}"); - None - } - }; - let entry_type = (match &entry.data { - rbw::db::EntryData::Login { .. } => "Login", - rbw::db::EntryData::Identity { .. } => "Identity", - rbw::db::EntryData::SshKey { .. } => "SSH Key", - rbw::db::EntryData::SecureNote => "Note", - rbw::db::EntryData::Card { .. } => "Card", - }) - .to_string(); - - Ok(DecryptedSearchCipher { - id, - entry_type, - folder, - name, - user, - uris, - fields, - notes, - }) -} - -fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { - // folder name should always be decrypted with the local key because - // folders are local to a specific user's vault, not the organization - let folder = entry - .folder - .as_ref() - .map(|folder| crate::actions::decrypt(folder, None, None)) - .transpose(); - let folder = match folder { - Ok(folder) => folder, - Err(e) => { - log::warn!("failed to decrypt folder name: {e}"); - None - } - }; - let fields = entry - .fields - .iter() - .map(|field| { - Ok(DecryptedField { - name: field - .name - .as_ref() - .map(|name| { - crate::actions::decrypt( - name, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - }) - .transpose()?, - value: field - .value - .as_ref() - .map(|value| { - crate::actions::decrypt( - value, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - }) - .transpose()?, - ty: field.ty, - }) - }) - .collect::>()?; - let notes = entry - .notes - .as_ref() - .map(|notes| { - crate::actions::decrypt( - notes, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - }) - .transpose(); - let notes = match notes { - Ok(notes) => notes, - Err(e) => { - log::warn!("failed to decrypt notes: {e}"); - None - } - }; - let history = entry - .history - .iter() - .map(|history_entry| { - Ok(DecryptedHistoryEntry { - last_used_date: history_entry.last_used_date.clone(), - password: crate::actions::decrypt( - &history_entry.password, - entry.key.as_deref(), - entry.org_id.as_deref(), - )?, - }) - }) - .collect::>()?; - - let data = match &entry.data { - rbw::db::EntryData::Login { - username, - password, - totp, - uris, - } => DecryptedData::Login { - username: decrypt_field( - Field::Username, - username.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - password: decrypt_field( - Field::Password, - password.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - totp: decrypt_field( - Field::Totp, - totp.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - uris: uris - .iter() - .map(|s| { - decrypt_field( - Field::Uris, - Some(&s.uri), - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .map(|uri| DecryptedUri { - uri, - match_type: s.match_type, - }) - }) - .collect(), - }, - rbw::db::EntryData::Card { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - } => DecryptedData::Card { - cardholder_name: decrypt_field( - Field::Cardholder, - cardholder_name.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - number: decrypt_field( - Field::CardNumber, - number.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - brand: decrypt_field( - Field::Brand, - brand.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - exp_month: decrypt_field( - Field::ExpMonth, - exp_month.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - exp_year: decrypt_field( - Field::ExpYear, - exp_year.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - code: decrypt_field( - Field::Cvv, - code.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - }, - rbw::db::EntryData::Identity { - title, - first_name, - middle_name, - last_name, - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - } => DecryptedData::Identity { - title: decrypt_field( - Field::Title, - title.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - first_name: decrypt_field( - Field::FirstName, - first_name.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - middle_name: decrypt_field( - Field::MiddleName, - middle_name.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - last_name: decrypt_field( - Field::LastName, - last_name.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - address1: decrypt_field( - Field::Address1, - address1.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - address2: decrypt_field( - Field::Address2, - address2.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - address3: decrypt_field( - Field::Address3, - address3.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - city: decrypt_field( - Field::City, - city.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - state: decrypt_field( - Field::State, - state.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - postal_code: decrypt_field( - Field::PostalCode, - postal_code.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - country: decrypt_field( - Field::Country, - country.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - phone: decrypt_field( - Field::Phone, - phone.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - email: decrypt_field( - Field::Email, - email.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - ssn: decrypt_field( - Field::Ssn, - ssn.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - license_number: decrypt_field( - Field::License, - license_number.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - passport_number: decrypt_field( - Field::Passport, - passport_number.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - username: decrypt_field( - Field::Username, - username.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - }, - rbw::db::EntryData::SecureNote => DecryptedData::SecureNote {}, - rbw::db::EntryData::SshKey { - public_key, - fingerprint, - private_key, - } => DecryptedData::SshKey { - public_key: decrypt_field( - Field::PublicKey, - public_key.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - fingerprint: decrypt_field( - Field::Fingerprint, - fingerprint.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - private_key: decrypt_field( - Field::PrivateKey, - private_key.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - }, - }; + let agent_version = version_or_quit()?; + if agent_version != client_version { + crate::actions::quit()?; + anyhow::bail!( + "client protocol version is {client_version} but agent protocol version is {agent_version}" + ); + } + Ok(()) +} - Ok(DecryptedCipher { - id: entry.id.clone(), - folder, - name: crate::actions::decrypt( - &entry.name, - entry.key.as_deref(), - entry.org_id.as_deref(), - )?, - data, - fields, - notes, - history, +fn version_or_quit() -> anyhow::Result { + crate::actions::version().inspect_err(|_| { + let _ = crate::actions::quit(); }) } -fn parse_editor(contents: &str) -> (Option, Option) { - let mut lines = contents.lines(); - - let password = lines.next().map(std::string::ToString::to_string); +async fn load_db() -> anyhow::Result { + let config = rbw::config::Config::load()?; - let mut notes: String = lines - .skip_while(|line| line.is_empty()) - .filter(|line| !line.starts_with('#')) - .fold(String::new(), |mut notes, line| { - notes.push_str(line); - notes.push('\n'); - notes - }); - while notes.ends_with('\n') { - notes.pop(); - } - let notes = if notes.is_empty() { None } else { Some(notes) }; + let Some(email) = &config.email else { + anyhow::bail!("failed to find email address in config"); + }; - (password, notes) + rbw::db::Db::load_async(&config.server_name(), email) + .await + .map_err(anyhow::Error::new) } -fn load_db() -> anyhow::Result { +async fn save_db(db: &rbw::db::Db) -> anyhow::Result<()> { let config = rbw::config::Config::load()?; - config.email.as_ref().map_or_else( - || Err(anyhow::anyhow!("failed to find email address in config")), - |email| { - rbw::db::Db::load(&config.server_name(), email) - .map_err(anyhow::Error::new) - }, - ) -} -fn save_db(db: &rbw::db::Db) -> anyhow::Result<()> { - let config = rbw::config::Config::load()?; - config.email.as_ref().map_or_else( - || Err(anyhow::anyhow!("failed to find email address in config")), - |email| { - db.save(&config.server_name(), email) - .map_err(anyhow::Error::new) - }, - ) + let Some(email) = &config.email else { + anyhow::bail!("failed to find email address in config"); + }; + + db.save_async(&config.server_name(), email) + .await + .map_err(anyhow::Error::new) } fn remove_db() -> anyhow::Result<()> { let config = rbw::config::Config::load()?; - config.email.as_ref().map_or_else( - || Err(anyhow::anyhow!("failed to find email address in config")), - |email| { - rbw::db::Db::remove(&config.server_name(), email) - .map_err(anyhow::Error::new) - }, - ) -} -struct TotpParams { - secret: Vec, - algorithm: String, - digits: usize, - period: u64, + let Some(email) = &config.email else { + anyhow::bail!("failed to find email address in config"); + }; + + rbw::db::Db::remove(&config.server_name(), email).map_err(anyhow::Error::new) } fn decode_totp_secret(secret: &str) -> anyhow::Result> { @@ -2703,112 +1178,29 @@ fn decode_totp_secret(secret: &str) -> anyhow::Result> { Err(anyhow::anyhow!("totp secret was not valid base32")) } -fn parse_totp_secret(secret: &str) -> anyhow::Result { - if let Ok(u) = url::Url::parse(secret) { - match u.scheme() { - "otpauth" => { - if u.host_str() != Some("totp") { - return Err(anyhow::anyhow!( - "totp secret url must have totp host" - )); - } - - let query: std::collections::HashMap<_, _> = - u.query_pairs().collect(); - - let secret = decode_totp_secret( - query.get("secret").ok_or_else(|| { - anyhow::anyhow!("totp secret url must have secret") - })?, - )?; - let algorithm = query.get("algorithm").map_or_else( - || String::from("SHA1"), - std::string::ToString::to_string, - ); - let digits = match query.get("digits") { - Some(dig) => dig - .parse::() - .map_err(|_| anyhow::anyhow!("digits parameter in totp url must be a valid integer."))?, - None => 6, - }; - let period = match query.get("period") { - Some(dig) => { - dig.parse::().map_err(|_| anyhow::anyhow!("period parameter in totp url must be a valid integer."))? - } - None => TOTP_DEFAULT_STEP, - }; - - Ok(TotpParams { - secret, - algorithm, - digits, - period, - }) - } - "steam" => { - let steam_secret = u.host_str().unwrap(); - - Ok(TotpParams { - secret: decode_totp_secret(steam_secret)?, - algorithm: String::from("STEAM"), - digits: 5, - period: TOTP_DEFAULT_STEP, - }) - } - _ => Err(anyhow::anyhow!( - "totp secret url must have 'otpauth' or 'steam' scheme" - )), - } - } else { - Ok(TotpParams { - secret: decode_totp_secret(secret)?, - algorithm: String::from("SHA1"), - digits: 6, - period: TOTP_DEFAULT_STEP, - }) - } -} - -// This function exists for the sake of making the generate_totp function less -// densely packed and more readable -fn generate_totp_algorithm_type( - alg: &str, -) -> anyhow::Result { - match alg { - "SHA1" => Ok(totp_rs::Algorithm::SHA1), - "SHA256" => Ok(totp_rs::Algorithm::SHA256), - "SHA512" => Ok(totp_rs::Algorithm::SHA512), - "STEAM" => Ok(totp_rs::Algorithm::Steam), - _ => Err(anyhow::anyhow!(format!("{alg} is not a valid algorithm"))), - } -} +// The default number of seconds the generated TOTP +// code lasts for before a new one must be generated +const TOTP_DEFAULT_STEP: u64 = 30; fn generate_totp(secret: &str) -> anyhow::Result { - let totp_params = parse_totp_secret(secret)?; - let alg = totp_params.algorithm.as_str(); - - match alg { - "SHA1" | "SHA256" | "SHA512" => Ok(totp_rs::TOTP::new_unchecked( - generate_totp_algorithm_type(alg)?, - totp_params.digits, - 1, // the library docs say this should be a 1 - totp_params.period, - totp_params.secret, - ) - .generate_current()?), - "STEAM" => Ok(totp_rs::TOTP::new_steam(totp_params.secret) - .generate_current()?), - _ => Err(anyhow::anyhow!(format!( - "{alg} is not a valid totp algorithm" - ))), - } -} + // Small hack that is not RFC compliant but helps with some services. + // Most authenticators have this built-in, included official Bitwarden clients. + let secret = secret.replace("algorithm=sha", "algorithm=SHA"); + + let totp = match totp_rs::TOTP::from_url(&secret) { + Ok(totp) => totp, + Err(_e) => totp_rs::TOTP::new_unchecked( + totp_rs::Algorithm::SHA1, + 6, + 1, + TOTP_DEFAULT_STEP, + decode_totp_secret(&secret)?, + None, + "".to_string(), + ), + }; -fn display_field(name: &str, field: Option<&str>, clipboard: bool) -> bool { - field.map_or_else( - || false, - |field| val_display_or_store(clipboard, &format!("{name}: {field}")), - ) + Ok(totp.generate_current()?) } #[cfg(test)] @@ -2886,25 +1278,11 @@ mod test { "BITWARDEN" ); assert!( - one_match( - entries, - "github", - Some("foo"), - Some("websites"), - 6, - false - ), + one_match(entries, "github", Some("foo"), Some("websites"), 6, false), "websites/foo@github" ); assert!( - one_match( - entries, - "GITHUB", - Some("foo"), - Some("websites"), - 6, - true - ), + one_match(entries, "GITHUB", Some("foo"), Some("websites"), 6, true), "websites/foo@GITHUB" ); assert!( @@ -3009,18 +1387,8 @@ mod test { make_entry("github", Some("foo"), None, &[]), make_entry("gitlab", Some("foo"), None, &[]), make_entry("gitlab", Some("bar"), None, &[]), - make_entry( - "12345678-1234-1234-1234-1234567890ab", - None, - None, - &[], - ), - make_entry( - "12345678-1234-1234-1234-1234567890AC", - None, - None, - &[], - ), + make_entry("12345678-1234-1234-1234-1234567890ab", None, None, &[]), + make_entry("12345678-1234-1234-1234-1234567890AC", None, None, &[]), make_entry("123456781234123412341234567890AD", None, None, &[]), ]; @@ -3109,19 +1477,9 @@ mod test { let entries = &[ make_entry("one", None, None, &[("https://one.com/", None)]), make_entry("two", None, None, &[("https://two.com/login", None)]), - make_entry( - "three", - None, - None, - &[("https://login.three.com/", None)], - ), + make_entry("three", None, None, &[("https://login.three.com/", None)]), make_entry("four", None, None, &[("four.com", None)]), - make_entry( - "five", - None, - None, - &[("https://five.com:8080/", None)], - ), + make_entry("five", None, None, &[("https://five.com:8080/", None)]), make_entry("six", None, None, &[("six.com:8080", None)]), make_entry("seven", None, None, &[("192.168.0.128:8080", None)]), ]; @@ -3131,14 +1489,7 @@ mod test { "one" ); assert!( - one_match( - entries, - "https://login.one.com/", - None, - None, - 0, - false - ), + one_match(entries, "https://login.one.com/", None, None, 0, false), "one" ); assert!( @@ -3158,26 +1509,12 @@ mod test { "two" ); assert!( - one_match( - entries, - "https://two.com/other-page", - None, - None, - 1, - false - ), + one_match(entries, "https://two.com/other-page", None, None, 1, false), "two" ); assert!( - one_match( - entries, - "https://login.three.com/", - None, - None, - 2, - false - ), + one_match(entries, "https://login.three.com/", None, None, 2, false), "three" ); assert!( @@ -3191,14 +1528,7 @@ mod test { ); assert!( - one_match( - entries, - "https://five.com:8080/", - None, - None, - 4, - false - ), + one_match(entries, "https://five.com:8080/", None, None, 4, false), "five" ); assert!( @@ -3215,14 +1545,7 @@ mod test { "six" ); assert!( - one_match( - entries, - "https://192.168.0.128:8080/", - None, - None, - 6, - false - ), + one_match(entries, "https://192.168.0.128:8080/", None, None, 6, false), "seven" ); assert!( @@ -3283,10 +1606,7 @@ mod test { "seven", None, None, - &[( - "192.168.0.128:8080", - Some(rbw::api::UriMatchType::Domain), - )], + &[("192.168.0.128:8080", Some(rbw::api::UriMatchType::Domain))], ), ]; @@ -3295,14 +1615,7 @@ mod test { "one" ); assert!( - one_match( - entries, - "https://login.one.com/", - None, - None, - 0, - false - ), + one_match(entries, "https://login.one.com/", None, None, 0, false), "one" ); assert!( @@ -3322,26 +1635,12 @@ mod test { "two" ); assert!( - one_match( - entries, - "https://two.com/other-page", - None, - None, - 1, - false - ), + one_match(entries, "https://two.com/other-page", None, None, 1, false), "two" ); assert!( - one_match( - entries, - "https://login.three.com/", - None, - None, - 2, - false - ), + one_match(entries, "https://login.three.com/", None, None, 2, false), "three" ); assert!( @@ -3355,14 +1654,7 @@ mod test { ); assert!( - one_match( - entries, - "https://five.com:8080/", - None, - None, - 4, - false - ), + one_match(entries, "https://five.com:8080/", None, None, 4, false), "five" ); assert!( @@ -3379,14 +1671,7 @@ mod test { "six" ); assert!( - one_match( - entries, - "https://192.168.0.128:8080/", - None, - None, - 6, - false - ), + one_match(entries, "https://192.168.0.128:8080/", None, None, 6, false), "seven" ); assert!( @@ -3408,10 +1693,7 @@ mod test { "two", None, None, - &[( - "https://two.com/login", - Some(rbw::api::UriMatchType::Host), - )], + &[("https://two.com/login", Some(rbw::api::UriMatchType::Host))], ), make_entry( "three", @@ -3432,10 +1714,7 @@ mod test { "five", None, None, - &[( - "https://five.com:8080/", - Some(rbw::api::UriMatchType::Host), - )], + &[("https://five.com:8080/", Some(rbw::api::UriMatchType::Host))], ), make_entry( "six", @@ -3476,26 +1755,12 @@ mod test { "two" ); assert!( - one_match( - entries, - "https://two.com/other-page", - None, - None, - 1, - false - ), + one_match(entries, "https://two.com/other-page", None, None, 1, false), "two" ); assert!( - one_match( - entries, - "https://login.three.com/", - None, - None, - 2, - false - ), + one_match(entries, "https://login.three.com/", None, None, 2, false), "three" ); assert!( @@ -3509,14 +1774,7 @@ mod test { ); assert!( - one_match( - entries, - "https://five.com:8080/", - None, - None, - 4, - false - ), + one_match(entries, "https://five.com:8080/", None, None, 4, false), "five" ); assert!( @@ -3533,14 +1791,7 @@ mod test { "six" ); assert!( - one_match( - entries, - "https://192.168.0.128:8080/", - None, - None, - 6, - false - ), + one_match(entries, "https://192.168.0.128:8080/", None, None, 6, false), "seven" ); assert!( @@ -3556,10 +1807,7 @@ mod test { "one", None, None, - &[( - "https://one.com/", - Some(rbw::api::UriMatchType::StartsWith), - )], + &[("https://one.com/", Some(rbw::api::UriMatchType::StartsWith))], ), make_entry( "two", @@ -3606,14 +1854,7 @@ mod test { "two" ); assert!( - one_match( - entries, - "https://two.com/login/sso", - None, - None, - 1, - false - ), + one_match(entries, "https://two.com/login/sso", None, None, 1, false), "two" ); assert!( @@ -3621,25 +1862,12 @@ mod test { "two" ); assert!( - no_matches( - entries, - "https://two.com/other-page", - None, - None, - false - ), + no_matches(entries, "https://two.com/other-page", None, None, false), "two" ); assert!( - one_match( - entries, - "https://login.three.com/", - None, - None, - 2, - false - ), + one_match(entries, "https://login.three.com/", None, None, 2, false), "three" ); assert!( @@ -3661,10 +1889,7 @@ mod test { "two", None, None, - &[( - "https://two.com/login", - Some(rbw::api::UriMatchType::Exact), - )], + &[("https://two.com/login", Some(rbw::api::UriMatchType::Exact))], ), make_entry( "three", @@ -3716,13 +1941,7 @@ mod test { "two" ); assert!( - no_matches( - entries, - "https://two.com/login/sso", - None, - None, - false - ), + no_matches(entries, "https://two.com/login/sso", None, None, false), "two" ); assert!( @@ -3730,25 +1949,12 @@ mod test { "two" ); assert!( - no_matches( - entries, - "https://two.com/other-page", - None, - None, - false - ), + no_matches(entries, "https://two.com/other-page", None, None, false), "two" ); assert!( - one_match( - entries, - "https://login.three.com/", - None, - None, - 2, - false - ), + one_match(entries, "https://login.three.com/", None, None, 2, false), "three" ); assert!( @@ -3830,14 +2036,7 @@ mod test { "two" ); assert!( - one_match( - entries, - "https://two.com/login/sso", - None, - None, - 1, - false - ), + one_match(entries, "https://two.com/login/sso", None, None, 1, false), "two" ); assert!( @@ -3845,25 +2044,12 @@ mod test { "two" ); assert!( - no_matches( - entries, - "https://two.com/other-page", - None, - None, - false - ), + no_matches(entries, "https://two.com/other-page", None, None, false), "two" ); assert!( - one_match( - entries, - "https://login.three.com/", - None, - None, - 2, - false - ), + one_match(entries, "https://login.three.com/", None, None, 2, false), "three" ); assert!( @@ -3889,10 +2075,7 @@ mod test { "two", None, None, - &[( - "https://two.com/login", - Some(rbw::api::UriMatchType::Never), - )], + &[("https://two.com/login", Some(rbw::api::UriMatchType::Never))], ), make_entry( "three", @@ -3951,24 +2134,12 @@ mod test { "two" ); assert!( - no_matches( - entries, - "https://two.com/other-page", - None, - None, - false - ), + no_matches(entries, "https://two.com/other-page", None, None, false), "two" ); assert!( - no_matches( - entries, - "https://login.three.com/", - None, - None, - false - ), + no_matches(entries, "https://login.three.com/", None, None, false), "three" ); assert!( @@ -4008,14 +2179,8 @@ mod test { None, None, &[ - ( - "https://one.com/", - Some(rbw::api::UriMatchType::Domain), - ), - ( - "https://two.com/", - Some(rbw::api::UriMatchType::Domain), - ), + ("https://one.com/", Some(rbw::api::UriMatchType::Domain)), + ("https://two.com/", Some(rbw::api::UriMatchType::Domain)), ], ), make_entry( @@ -4052,7 +2217,7 @@ mod test { #[track_caller] fn one_match( - entries: &[(rbw::db::Entry, DecryptedSearchCipher)], + entries: &[(rbw::db::Entry, SearchEntry)], needle: &str, username: Option<&str>, folder: Option<&str>, @@ -4062,7 +2227,7 @@ mod test { entries_eq( &find_entry_raw( entries, - &parse_needle(needle).unwrap(), + &needle.parse().unwrap(), username, folder, ignore_case, @@ -4074,7 +2239,7 @@ mod test { #[track_caller] fn no_matches( - entries: &[(rbw::db::Entry, DecryptedSearchCipher)], + entries: &[(rbw::db::Entry, SearchEntry)], needle: &str, username: Option<&str>, folder: Option<&str>, @@ -4082,7 +2247,7 @@ mod test { ) -> bool { let res = find_entry_raw( entries, - &parse_needle(needle).unwrap(), + &needle.parse().unwrap(), username, folder, ignore_case, @@ -4096,7 +2261,7 @@ mod test { #[track_caller] fn many_matches( - entries: &[(rbw::db::Entry, DecryptedSearchCipher)], + entries: &[(rbw::db::Entry, SearchEntry)], needle: &str, username: Option<&str>, folder: Option<&str>, @@ -4104,7 +2269,7 @@ mod test { ) -> bool { let res = find_entry_raw( entries, - &parse_needle(needle).unwrap(), + &needle.parse().unwrap(), username, folder, ignore_case, @@ -4118,8 +2283,8 @@ mod test { #[track_caller] fn entries_eq( - a: &(rbw::db::Entry, DecryptedSearchCipher), - b: &(rbw::db::Entry, DecryptedSearchCipher), + a: &(rbw::db::Entry, SearchEntry), + b: &(rbw::db::Entry, SearchEntry), ) -> bool { a.0 == b.0 && a.1 == b.1 } @@ -4129,19 +2294,17 @@ mod test { username: Option<&str>, folder: Option<&str>, uris: &[(&str, Option)], - ) -> (rbw::db::Entry, DecryptedSearchCipher) { + ) -> (rbw::db::Entry, SearchEntry) { let id = uuid::Uuid::new_v4(); ( - rbw::db::Entry { + rbw::db::Entry:: { id: id.to_string(), org_id: None, folder: folder.map(|_| "encrypted folder name".to_string()), folder_id: None, name: "this is the encrypted name".to_string(), data: rbw::db::EntryData::Login { - username: username.map(|_| { - "this is the encrypted username".to_string() - }), + username: username.map(|_| "this is the encrypted username".to_string()), password: None, uris: uris .iter() @@ -4157,18 +2320,17 @@ mod test { history: vec![], key: None, master_password_reprompt: rbw::api::CipherRepromptType::None, + _state: std::marker::PhantomData, }, - DecryptedSearchCipher { + SearchEntry { id: id.to_string(), entry_type: "Login".to_string(), - folder: folder.map(std::string::ToString::to_string), + folder: folder.map(ToString::to_string), name: name.to_string(), - user: username.map(std::string::ToString::to_string), + user: username.map(ToString::to_string), uris: uris .iter() - .map(|(uri, match_type)| { - ((*uri).to_string(), *match_type) - }) + .map(|(uri, match_type)| ((*uri).to_string(), *match_type)) .collect(), fields: vec![], notes: None, diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index ff2ec740..8147bbca 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -8,9 +8,9 @@ mod commands; mod sock; #[derive(Debug, clap::Args)] -struct FindArgs { - #[arg(help = "Name, URI or UUID of the entry to display", value_parser = commands::parse_needle)] - needle: commands::Needle, +pub struct FindArgs { + #[arg(help = "Name, URI or UUID of the entry to display")] + needle: rbw::search::Needle, #[arg(help = "Username of the entry to display")] user: Option, #[arg(long, help = "Folder name to search in")] @@ -131,11 +131,7 @@ enum Opt { name: String, #[arg(help = "Username for the password entry")] user: Option, - #[arg( - long, - help = "URI for the password entry", - number_of_values = 1 - )] + #[arg(long, help = "URI for the password entry", number_of_values = 1)] uri: Vec, #[arg(long, help = "Folder for the password entry")] folder: Option, @@ -161,11 +157,7 @@ enum Opt { name: Option, #[arg(help = "Username for the password entry")] user: Option, - #[arg( - long, - help = "URI for the password entry", - number_of_values = 1 - )] + #[arg(long, help = "URI for the password entry", number_of_values = 1)] uri: Vec, #[arg(long, help = "Folder for the password entry")] folder: Option, @@ -307,23 +299,73 @@ impl Config { } } -fn main() { - let opt = Opt::parse(); +fn generate_completion(generator: G) { + clap_complete::generate( + generator, + &mut Opt::command(), + "rbw", + &mut std::io::stdout(), + ); +} - env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or("info"), - ) - .format(|buf, record| { - if let Some((terminal_size::Width(w), _)) = - terminal_size::terminal_size() - { - let out = format!("{}: {}", record.level(), record.args()); - writeln!(buf, "{}", textwrap::fill(&out, usize::from(w) - 1)) - } else { - writeln!(buf, "{}: {}", record.level(), record.args()) +fn gen_completions(shell: CompletionShell) { + match shell { + CompletionShell::Bash => { + generate_completion(clap_complete::Shell::Bash); + println!("{}", include_str!("completion/rbw.bash")); + } + CompletionShell::Fish => { + generate_completion(clap_complete::Shell::Fish); + println!("{}", include_str!("completion/rbw.fish")); + } + CompletionShell::Zsh => { + generate_completion(clap_complete::Shell::Zsh); + println!("{}", include_str!("completion/rbw.zsh")); + } + CompletionShell::Powershell => { + generate_completion(clap_complete::Shell::PowerShell); + } + CompletionShell::Elvish => { + generate_completion(clap_complete::Shell::Elvish); + } + CompletionShell::Nushell => { + generate_completion(clap_complete_nushell::Nushell); } - }) - .init(); + CompletionShell::Fig => { + generate_completion(clap_complete_fig::Fig); + } + } +} + +fn calc_pwgen_type( + no_symbols: bool, + only_numbers: bool, + nonconfusables: bool, + diceware: bool, +) -> rbw::pwgen::Type { + match (no_symbols, only_numbers, nonconfusables, diceware) { + (true, ..) => rbw::pwgen::Type::NoSymbols, + (_, true, ..) => rbw::pwgen::Type::Numbers, + (_, _, true, _) => rbw::pwgen::Type::NonConfusables, + (.., true) => rbw::pwgen::Type::Diceware, + _ => rbw::pwgen::Type::AllChars, + } +} + +#[tokio::main] +async fn main() { + let opt = Opt::parse(); + + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format(|buf, record| { + if let Some((terminal_size::Width(w), _)) = terminal_size::terminal_size() { + let out = format!("{}: {}", record.level(), record.args()); + writeln!(buf, "{}", textwrap::fill(&out, usize::from(w) - 1)) + } else { + writeln!(buf, "{}: {}", record.level(), record.args()) + } + }) + .init(); let subcommand_name = opt.subcommand_name(); let res = match opt { @@ -337,7 +379,7 @@ fn main() { Opt::Unlock => commands::unlock(), Opt::Unlocked => commands::unlocked(), Opt::Sync => commands::sync(), - Opt::List { fields, raw } => commands::list(&fields, raw), + Opt::List { fields, raw } => commands::list(&fields, raw).await, Opt::Get { find_args, field, @@ -346,55 +388,59 @@ fn main() { #[cfg(feature = "clipboard")] clipboard, list_fields, - } => commands::get( - find_args.needle.clone(), - find_args.user.as_deref(), - find_args.folder.as_deref(), - field.as_deref(), - full, - raw, - #[cfg(feature = "clipboard")] - clipboard, - #[cfg(not(feature = "clipboard"))] - false, - find_args.ignorecase, - list_fields, - ), + } => { + commands::get( + find_args, + field.as_deref(), + full, + raw, + #[cfg(feature = "clipboard")] + clipboard, + #[cfg(not(feature = "clipboard"))] + false, + list_fields, + ) + .await + } Opt::Search { term, fields, folder, raw, - } => commands::search(&term, &fields, folder.as_deref(), raw), + } => commands::search(&term, &fields, folder.as_deref(), raw).await, Opt::Code { find_args, #[cfg(feature = "clipboard")] clipboard, - } => commands::code( - find_args.needle, - find_args.user.as_deref(), - find_args.folder.as_deref(), - #[cfg(feature = "clipboard")] - clipboard, - #[cfg(not(feature = "clipboard"))] - false, - find_args.ignorecase, - ), + } => { + commands::code( + find_args, + #[cfg(feature = "clipboard")] + clipboard, + #[cfg(not(feature = "clipboard"))] + false, + ) + .await + } Opt::Add { name, user, uri, folder, - } => commands::add( - &name, - user.as_deref(), - &uri.iter() - // XXX not sure what the ui for specifying the match type - // should be - .map(|uri| (uri.clone(), None)) - .collect::>(), - folder.as_deref(), - ), + } => { + commands::add( + &name, + user.as_deref(), + &uri.iter() + // XXX not sure what the ui for specifying the match type + // should be + .map(|uri| (uri.clone(), None)) + .collect::>(), + folder.as_deref(), + None, + ) + .await + } Opt::Generate { len, name, @@ -406,17 +452,6 @@ fn main() { nonconfusables, diceware, } => { - let ty = if no_symbols { - rbw::pwgen::Type::NoSymbols - } else if only_numbers { - rbw::pwgen::Type::Numbers - } else if nonconfusables { - rbw::pwgen::Type::NonConfusables - } else if diceware { - rbw::pwgen::Type::Diceware - } else { - rbw::pwgen::Type::AllChars - }; commands::generate( name.as_deref(), user.as_deref(), @@ -427,92 +462,18 @@ fn main() { .collect::>(), folder.as_deref(), len, - ty, + calc_pwgen_type(no_symbols, only_numbers, nonconfusables, diceware), ) + .await } - Opt::Edit { find_args } => commands::edit( - find_args.needle, - find_args.user.as_deref(), - find_args.folder.as_deref(), - find_args.ignorecase, - ), - Opt::Remove { find_args } => commands::remove( - find_args.needle, - find_args.user.as_deref(), - find_args.folder.as_deref(), - find_args.ignorecase, - ), - Opt::History { find_args } => commands::history( - find_args.needle, - find_args.user.as_deref(), - find_args.folder.as_deref(), - find_args.ignorecase, - ), + Opt::Edit { find_args } => commands::edit(find_args).await, + Opt::Remove { find_args } => commands::remove(find_args).await, + Opt::History { find_args } => commands::history(find_args).await, Opt::Lock => commands::lock(), Opt::Purge => commands::purge(), Opt::StopAgent => commands::stop_agent(), Opt::GenCompletions { shell } => { - match shell { - CompletionShell::Bash => { - clap_complete::generate( - clap_complete::Shell::Bash, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); - println!("{}", include_str!("completion/rbw.bash")); - } - CompletionShell::Fish => { - clap_complete::generate( - clap_complete::Shell::Fish, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); - println!("{}", include_str!("completion/rbw.fish")); - } - CompletionShell::Zsh => { - clap_complete::generate( - clap_complete::Shell::Zsh, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); - println!("{}", include_str!("completion/rbw.zsh")); - } - CompletionShell::Powershell => { - clap_complete::generate( - clap_complete::Shell::PowerShell, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); - } - CompletionShell::Elvish => { - clap_complete::generate( - clap_complete::Shell::Elvish, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); - } - CompletionShell::Nushell => { - clap_complete::generate( - clap_complete_nushell::Nushell, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); - } - CompletionShell::Fig => { - clap_complete::generate( - clap_complete_fig::Fig, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); - } - } + gen_completions(shell); Ok(()) } } diff --git a/src/bin/rbw/sock.rs b/src/bin/rbw/sock.rs index ab66e5dd..bd0ead47 100644 --- a/src/bin/rbw/sock.rs +++ b/src/bin/rbw/sock.rs @@ -9,14 +9,11 @@ impl Sock { // specific kinds of std::io::Results differently pub fn connect() -> std::io::Result { Ok(Self(std::os::unix::net::UnixStream::connect( - rbw::dirs::socket_file(), + rbw::dirs::socket_file().map_err(std::io::Error::other)?, )?)) } - pub fn send( - &mut self, - msg: &rbw::protocol::Request, - ) -> anyhow::Result<()> { + pub fn send(&mut self, msg: &rbw::protocol::Request) -> anyhow::Result<()> { let Self(sock) = self; sock.write_all( serde_json::to_string(msg) @@ -35,7 +32,6 @@ impl Sock { let mut line = String::new(); buf.read_line(&mut line) .context("failed to read message from agent")?; - serde_json::from_str(&line) - .context("failed to parse message from agent") + serde_json::from_str(&line).context("failed to parse message from agent") } } diff --git a/src/cipherstring.rs b/src/cipherstring.rs index e42b676b..0faa6376 100644 --- a/src/cipherstring.rs +++ b/src/cipherstring.rs @@ -1,8 +1,6 @@ use crate::prelude::*; -use aes::cipher::{ - BlockDecryptMut as _, BlockEncryptMut as _, KeyIvInit as _, -}; +use aes::cipher::{BlockDecryptMut as _, BlockEncryptMut as _, KeyIvInit as _}; use hmac::Mac as _; use pkcs8::DecodePrivateKey as _; use rand::RngCore as _; @@ -45,10 +43,7 @@ impl CipherString { let parts: Vec<&str> = contents.split('|').collect(); if parts.len() < 2 || parts.len() > 3 { return Err(Error::InvalidCipherString { - reason: format!( - "type 2 cipherstring with {} parts", - parts.len() - ), + reason: format!("type 2 cipherstring with {} parts", parts.len()), }); } @@ -56,14 +51,14 @@ impl CipherString { .map_err(|source| Error::InvalidBase64 { source })?; let ciphertext = crate::base64::decode(parts[1]) .map_err(|source| Error::InvalidBase64 { source })?; - let mac = - if parts.len() > 2 { - Some(crate::base64::decode(parts[2]).map_err( - |source| Error::InvalidBase64 { source }, - )?) - } else { - None - }; + let mac = if parts.len() > 2 { + Some( + crate::base64::decode(parts[2]) + .map_err(|source| Error::InvalidBase64 { source })?, + ) + } else { + None + }; Ok(Self::Symmetric { iv, @@ -85,30 +80,21 @@ impl CipherString { if ty < 6 { Err(Error::TooOldCipherStringType { ty: ty.to_string() }) } else { - Err(Error::UnimplementedCipherStringType { - ty: ty.to_string(), - }) + Err(Error::UnimplementedCipherStringType { ty: ty.to_string() }) } } } } - pub fn encrypt_symmetric( - keys: &crate::locked::Keys, - plaintext: &[u8], - ) -> Result { + pub fn encrypt_symmetric(keys: &crate::locked::Keys, plaintext: &[u8]) -> Result { let iv = random_iv(); - let cipher = cbc::Encryptor::::new( - keys.enc_key().into(), - iv.as_slice().into(), - ); - let ciphertext = - cipher.encrypt_padded_vec_mut::(plaintext); + let cipher = + cbc::Encryptor::::new(keys.enc_key().into(), iv.as_slice().into()); + let ciphertext = cipher.encrypt_padded_vec_mut::(plaintext); - let mut digest = - hmac::Hmac::::new_from_slice(keys.mac_key()) - .map_err(|source| Error::CreateHmac { source })?; + let mut digest = hmac::Hmac::::new_from_slice(keys.mac_key()) + .map_err(|source| Error::CreateHmac { source })?; digest.update(&iv); digest.update(&ciphertext); let mac = digest.finalize().into_bytes().as_slice().to_vec(); @@ -125,90 +111,74 @@ impl CipherString { keys: &crate::locked::Keys, entry_key: Option<&crate::locked::Keys>, ) -> Result> { - if let Self::Symmetric { + let Self::Symmetric { iv, ciphertext, mac, } = self - { - let cipher = decrypt_common_symmetric( - entry_key.unwrap_or(keys), - iv, - ciphertext, - mac.as_deref(), - )?; - cipher - .decrypt_padded_vec_mut::(ciphertext) - .map_err(|source| Error::Decrypt { source }) - } else { - Err(Error::InvalidCipherString { - reason: - "found an asymmetric cipherstring, expecting symmetric" - .to_string(), - }) - } + else { + return Err(Error::InvalidCipherString { + reason: "found an asymmetric cipherstring, expecting symmetric".to_string(), + }); + }; + + let cipher = + decrypt_common_symmetric(entry_key.unwrap_or(keys), iv, ciphertext, mac.as_deref())?; + cipher + .decrypt_padded_vec_mut::(ciphertext) + .map_err(|source| Error::Decrypt { source }) } pub fn decrypt_locked_symmetric( &self, keys: &crate::locked::Keys, - ) -> Result { - if let Self::Symmetric { + ) -> Result { + let Self::Symmetric { iv, ciphertext, mac, } = self - { - let mut res = crate::locked::Vec::new(); - res.extend(ciphertext.iter().copied()); - let cipher = decrypt_common_symmetric( - keys, - iv, - ciphertext, - mac.as_deref(), - )?; - cipher - .decrypt_padded_mut::(res.data_mut()) - .map_err(|source| Error::Decrypt { source })?; - Ok(res) - } else { - Err(Error::InvalidCipherString { - reason: - "found an asymmetric cipherstring, expecting symmetric" - .to_string(), - }) - } + else { + return Err(Error::InvalidCipherString { + reason: "found an asymmetric cipherstring, expecting symmetric".to_string(), + }); + }; + + let mut res = crate::locked::LockedVec::new(); + res.extend(ciphertext.iter().copied()); + let cipher = decrypt_common_symmetric(keys, iv, ciphertext, mac.as_deref())?; + cipher + .decrypt_padded_mut::(&mut res) + .map_err(|source| Error::Decrypt { source })?; + Ok(res) } pub fn decrypt_locked_asymmetric( &self, private_key: &crate::locked::PrivateKey, - ) -> Result { - if let Self::Asymmetric { ciphertext } = self { - let privkey_data = private_key.private_key(); - let privkey_data = - pkcs7_unpad(privkey_data).ok_or(Error::Padding)?; - let pkey = rsa::RsaPrivateKey::from_pkcs8_der(privkey_data) - .map_err(|source| Error::RsaPkcs8 { source })?; - let mut bytes = pkey - .decrypt(rsa::Oaep::new::(), ciphertext) - .map_err(|source| Error::Rsa { source })?; + ) -> Result { + let Self::Asymmetric { ciphertext } = self else { + return Err(Error::InvalidCipherString { + reason: "found a symmetric cipherstring, expecting asymmetric".to_string(), + }); + }; - // XXX it'd be great if the rsa crate would let us decrypt - // into a preallocated buffer directly to avoid the - // intermediate vec that needs to be manually zeroized, etc - let mut res = crate::locked::Vec::new(); - res.extend(bytes.iter().copied()); - bytes.zeroize(); + let privkey_data = private_key.private_key(); + let privkey_data = pkcs7_unpad(privkey_data).ok_or(Error::Padding)?; + let pkey = rsa::RsaPrivateKey::from_pkcs8_der(privkey_data) + .map_err(|source| Error::RsaPkcs8 { source })?; + let mut bytes = pkey + .decrypt(rsa::Oaep::new::(), ciphertext) + .map_err(|source| Error::Rsa { source })?; - Ok(res) - } else { - Err(Error::InvalidCipherString { - reason: - "found a symmetric cipherstring, expecting asymmetric" - .to_string(), - }) - } + // XXX it'd be great if the rsa crate would let us decrypt + // into a preallocated buffer directly to avoid the + // intermediate vec that needs to be manually zeroized, etc + let mut res = crate::locked::LockedVec::new(); + res.extend(bytes.iter().copied()); + bytes.zeroize(); + + Ok(res) } } @@ -219,9 +189,8 @@ fn decrypt_common_symmetric( mac: Option<&[u8]>, ) -> Result> { if let Some(mac) = mac { - let mut key = - hmac::Hmac::::new_from_slice(keys.mac_key()) - .map_err(|source| Error::CreateHmac { source })?; + let mut key = hmac::Hmac::::new_from_slice(keys.mac_key()) + .map_err(|source| Error::CreateHmac { source })?; key.update(iv); key.update(ciphertext); diff --git a/src/config.rs b/src/config.rs index 248c603c..800b9102 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,7 @@ pub struct Config { pub sync_interval: u64, #[serde(default = "default_pinentry")] pub pinentry: String, + pub confirm_ssh: Option, pub client_cert_path: Option, // backcompat, no longer generated in new configs #[serde(skip_serializing)] @@ -36,6 +37,7 @@ impl Default for Config { lock_timeout: default_lock_timeout(), sync_interval: default_sync_interval(), pinentry: default_pinentry(), + confirm_ssh: None, client_cert_path: None, device_id: None, } @@ -54,18 +56,22 @@ pub fn default_pinentry() -> String { "pinentry".to_string() } +pub fn default_confirm_ssh() -> Option { + None +} + +const BW_EU_URL: &str = "https://api.bitwarden.eu"; + impl Config { pub fn new() -> Self { Self::default() } pub fn load() -> Result { - let file = crate::dirs::config_file(); - let mut fh = std::fs::File::open(&file).map_err(|source| { - Error::LoadConfig { - source, - file: file.clone(), - } + let file = crate::dirs::config_file()?; + let mut fh = std::fs::File::open(&file).map_err(|source| Error::LoadConfig { + source, + file: file.clone(), })?; let mut json = String::new(); fh.read_to_string(&mut json) @@ -73,33 +79,8 @@ impl Config { source, file: file.clone(), })?; - let mut slf: Self = serde_json::from_str(&json) - .map_err(|source| Error::LoadConfigJson { source, file })?; - if slf.lock_timeout == 0 { - log::warn!("lock_timeout must be greater than 0"); - slf.lock_timeout = default_lock_timeout(); - } - Ok(slf) - } - - pub async fn load_async() -> Result { - let file = crate::dirs::config_file(); - let mut fh = - tokio::fs::File::open(&file).await.map_err(|source| { - Error::LoadConfigAsync { - source, - file: file.clone(), - } - })?; - let mut json = String::new(); - fh.read_to_string(&mut json).await.map_err(|source| { - Error::LoadConfigAsync { - source, - file: file.clone(), - } - })?; - let mut slf: Self = serde_json::from_str(&json) - .map_err(|source| Error::LoadConfigJson { source, file })?; + let mut slf: Self = + serde_json::from_str(&json).map_err(|source| Error::LoadConfigJson { source, file })?; if slf.lock_timeout == 0 { log::warn!("lock_timeout must be greater than 0"); slf.lock_timeout = default_lock_timeout(); @@ -108,20 +89,16 @@ impl Config { } pub fn save(&self) -> Result<()> { - let file = crate::dirs::config_file(); + let file = crate::dirs::config_file()?; // unwrap is safe here because Self::filename is explicitly // constructed as a filename in a directory - std::fs::create_dir_all(file.parent().unwrap()).map_err( - |source| Error::SaveConfig { - source, - file: file.clone(), - }, - )?; - let mut fh = std::fs::File::create(&file).map_err(|source| { - Error::SaveConfig { - source, - file: file.clone(), - } + std::fs::create_dir_all(file.parent().unwrap()).map_err(|source| Error::SaveConfig { + source, + file: file.clone(), + })?; + let mut fh = std::fs::File::create(&file).map_err(|source| Error::SaveConfig { + source, + file: file.clone(), })?; fh.write_all( serde_json::to_string(self) @@ -143,64 +120,54 @@ impl Config { Ok(()) } - pub fn base_url(&self) -> String { - self.base_url.clone().map_or_else( - || "https://api.bitwarden.com".to_string(), - |url| { - let clean_url = url.trim_end_matches('/'); - if clean_url == "https://api.bitwarden.eu" { - "https://api.bitwarden.eu".to_string() + fn resolve_url(&self, default: String, eu_default: String, suffix: &str) -> String { + match &self.base_url { + Some(url) => { + let url = url.trim_end_matches('/'); + if url == BW_EU_URL { + eu_default } else { - format!("{clean_url}/api") + format!("{url}{suffix}") } - }, + } + None => default, + } + } + + pub fn base_url(&self) -> String { + self.resolve_url( + "https://api.bitwarden.com".to_string(), + "https://api.bitwarden.eu".to_string(), + "/api", ) } pub fn identity_url(&self) -> String { self.identity_url.clone().unwrap_or_else(|| { - self.base_url.clone().map_or_else( - || "https://identity.bitwarden.com".to_string(), - |url| { - let clean_url = url.trim_end_matches('/'); - if clean_url == "https://api.bitwarden.eu" { - "https://identity.bitwarden.eu".to_string() - } else { - format!("{clean_url}/identity") - } - }, + self.resolve_url( + "https://identity.bitwarden.com".to_string(), + "https://identity.bitwarden.eu".to_string(), + "/identity", ) }) } pub fn ui_url(&self) -> String { self.ui_url.clone().unwrap_or_else(|| { - self.base_url.clone().map_or_else( - || "https://vault.bitwarden.com".to_string(), - |url| { - let clean_url = url.trim_end_matches('/'); - if clean_url == "https://api.bitwarden.eu" { - "https://vault.bitwarden.eu".to_string() - } else { - clean_url.to_string() - } - }, + self.resolve_url( + "https://vault.bitwarden.com".to_string(), + "https://vault.bitwarden.eu".to_string(), + "", ) }) } pub fn notifications_url(&self) -> String { self.notifications_url.clone().unwrap_or_else(|| { - self.base_url.clone().map_or_else( - || "https://notifications.bitwarden.com".to_string(), - |url| { - let clean_url = url.trim_end_matches('/'); - if clean_url == "https://api.bitwarden.eu" { - "https://notifications.bitwarden.eu".to_string() - } else { - format!("{clean_url}/notifications") - } - }, + self.resolve_url( + "https://notifications.bitwarden.com".to_string(), + "https://notifications.bitwarden.eu".to_string(), + "/notifications", ) }) } @@ -217,7 +184,7 @@ impl Config { } pub async fn device_id(config: &Config) -> Result { - let file = crate::dirs::device_id_file(); + let file = crate::dirs::device_id_file()?; if let Ok(mut fh) = tokio::fs::File::open(&file).await { let mut s = String::new(); fh.read_to_string(&mut s) @@ -232,18 +199,18 @@ pub async fn device_id(config: &Config) -> Result { || uuid::Uuid::new_v4().hyphenated().to_string(), String::to_string, ); - let mut fh = tokio::fs::File::create(&file).await.map_err(|e| { - Error::LoadDeviceId { + let mut fh = tokio::fs::File::create(&file) + .await + .map_err(|e| Error::LoadDeviceId { source: e, file: file.clone(), - } - })?; - fh.write_all(id.as_bytes()).await.map_err(|e| { - Error::LoadDeviceId { + })?; + fh.write_all(id.as_bytes()) + .await + .map_err(|e| Error::LoadDeviceId { source: e, file: file.clone(), - } - })?; + })?; Ok(id) } } diff --git a/src/db.rs b/src/db.rs index fec0af7c..9fa6972a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,38 +1,804 @@ -use crate::prelude::*; +use crate::{ + actions::{CryptoParameters, SessionParameters}, + prelude::*, +}; -use std::io::{Read as _, Write as _}; +use std::{collections::HashMap, fmt::Display}; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; -#[derive( - serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, -)] -pub struct Entry { +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum FieldType { + Notes, + Username, + Password, + Totp, + Uris, + IdentityName, + City, + State, + PostalCode, + Country, + Phone, + Ssn, + License, + Passport, + CardNumber, + Expiration, + ExpMonth, + ExpYear, + Cvv, + Cardholder, + Brand, + Name, + Email, + Address, + Address1, + Address2, + Address3, + Fingerprint, + PublicKey, + PrivateKey, + Title, + FirstName, + MiddleName, + LastName, + Custom(String), +} + +impl From<&str> for FieldType { + fn from(s: &str) -> Self { + match s.to_lowercase().as_str() { + "notes" | "note" => Self::Notes, + "username" | "user" => Self::Username, + "password" => Self::Password, + "totp" | "code" => Self::Totp, + "uris" | "urls" | "sites" => Self::Uris, + "identityname" => Self::IdentityName, + "city" => Self::City, + "state" => Self::State, + "postcode" | "zipcode" | "zip" => Self::PostalCode, + "country" => Self::Country, + "phone" => Self::Phone, + "ssn" => Self::Ssn, + "license" => Self::License, + "passport" => Self::Passport, + "number" | "card" => Self::CardNumber, + "exp" => Self::Expiration, + "exp_month" | "month" => Self::ExpMonth, + "exp_year" | "year" => Self::ExpYear, + // the word "code" got preceeded by Totp + "cvv" => Self::Cvv, + "cardholder" | "cardholder_name" => Self::Cardholder, + "brand" | "type" => Self::Brand, + "name" => Self::Name, + "email" => Self::Email, + "address1" => Self::Address1, + "address2" => Self::Address2, + "address3" => Self::Address3, + "address" => Self::Address, + "fingerprint" => Self::Fingerprint, + "public_key" => Self::PublicKey, + "private_key" => Self::PrivateKey, + "title" => Self::Title, + "first_name" => Self::FirstName, + "middle_name" => Self::MiddleName, + "last_name" => Self::LastName, + _ => Self::Custom(s.to_string()), + } + } +} + +impl Display for FieldType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Notes => "notes", + Self::Username => "username", + Self::Password => "password", + Self::Totp => "totp", + Self::Uris => "uris", + Self::IdentityName => "identityname", + Self::City => "city", + Self::State => "state", + Self::PostalCode => "postcode", + Self::Country => "country", + Self::Phone => "phone", + Self::Ssn => "ssn", + Self::License => "license", + Self::Passport => "passport", + Self::CardNumber => "number", + Self::Expiration => "exp", + Self::ExpMonth => "exp_month", + Self::ExpYear => "exp_year", + Self::Cvv => "cvv", + Self::Cardholder => "cardholder", + Self::Brand => "brand", + Self::Name => "name", + Self::Email => "email", + Self::Address1 => "address1", + Self::Address2 => "address2", + Self::Address3 => "address3", + Self::Address => "address", + Self::Fingerprint => "fingerprint", + Self::PublicKey => "public_key", + Self::PrivateKey => "private_key", + Self::Title => "title", + Self::FirstName => "first_name", + Self::MiddleName => "middle_name", + Self::LastName => "last_name", + Self::Custom(name) => name, + }) + } +} + +/// Used to describe custom fields in the application. +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] +pub struct DynamicField { + pub ty: Option, + pub name: Option, + pub value: Option, + pub linked_id: Option, +} + +#[allow(clippy::large_enum_variant)] +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] +pub enum EntryData { + Login { + username: Option, + password: Option, + totp: Option, + uris: Vec, + }, + Card { + cardholder_name: Option, + number: Option, + brand: Option, + exp_month: Option, + exp_year: Option, + code: Option, + }, + Identity { + title: Option, + first_name: Option, + middle_name: Option, + last_name: Option, + address1: Option, + address2: Option, + address3: Option, + city: Option, + state: Option, + postal_code: Option, + country: Option, + phone: Option, + email: Option, + ssn: Option, + license_number: Option, + passport_number: Option, + username: Option, + }, + SecureNote, + SshKey { + private_key: Option, + public_key: Option, + fingerprint: Option, + }, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] +pub struct HistoryEntry { + pub last_used_date: String, + pub password: String, +} + +// These are markers for type state pattern +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Encrypted; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Decrypted; + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] +pub struct Entry { pub id: String, pub org_id: Option, pub folder: Option, pub folder_id: Option, pub name: String, pub data: EntryData, - pub fields: Vec, + pub fields: Vec, pub notes: Option, pub history: Vec, pub key: Option, pub master_password_reprompt: crate::api::CipherRepromptType, + #[serde(skip)] + pub _state: std::marker::PhantomData, } -impl Entry { +// Most impl fn don't belong here. I am talking of display ones, but looking to relocate them +// later in the refactor process. +impl Entry { pub fn master_password_reprompt(&self) -> bool { self.master_password_reprompt != crate::api::CipherRepromptType::None } } +impl Entry { + /// The "short" is the first field that comes to mind when speaking of a entry, like the + /// password for the Login , the number for the Card, etc. + pub fn get_short(&self) -> Option { + match &self.data { + EntryData::Login { password, .. } => password.clone(), + EntryData::Card { number, .. } => number.clone(), + EntryData::Identity { + title, + first_name, + middle_name, + last_name, + .. + } => { + let names: Vec = [title, first_name, middle_name, last_name] + .iter() + .copied() + .flatten() + .cloned() + .collect(); + + if names.is_empty() { + None + } else { + Some(names.join(" ")) + } + } + EntryData::SecureNote => self.notes.clone(), + EntryData::SshKey { public_key, .. } => public_key.clone(), + } + } + + /// Ugly function. Its job could be handled semi-automatically by the type system. + /// Doesn't need to be "Decrypted" to work. + pub fn get_fields_list(&self) -> Vec { + let mut ret = vec![]; + + for (k, _) in self.static_fields() { + ret.push(k.to_string()); + } + + for (k, _) in self.custom_fields() { + ret.push(k); + } + + ret + } + + /// Given a textual representation of a field, like "username", "password" or "card number", + /// check which type of entry EntryData is and extract the "username" or "cardnumber" field if + /// available from the "static" fields, else go check for the dynamic ones. + /// For example, if the EntryData is of type EntryData::Login, try to extract the username from the + /// static username field, but if the field param is not within the static fields, search for it through the dynamic ones. + /// The dynamic fields are the user's added ones and labeled as "Custom field" in GUI apps. + pub fn get_field( + &self, + field_key: &str, + generate_totp: fn(&str) -> anyhow::Result, + ) -> Vec { + let mut ret = vec![]; + let ftype: FieldType = field_key.into(); + + if let FieldType::Custom(field_key) = ftype { + if let Some(value) = self.custom_fields().remove(&field_key) { + value.into_iter().for_each(|i| ret.push(i)); + } + } else { + if let Some(value) = self.static_fields().remove(&ftype) { + let value = if ftype == FieldType::Totp { + match generate_totp(&value) { + Ok(totp) => totp, + Err(e) => { + eprintln!("{e}"); + String::new() + } + } + } else { + value + }; + + ret.push(value); + } + } + + ret + } + + pub fn static_fields(&self) -> HashMap { + let mut map = HashMap::new(); + + let mut ins = |k, v: &Option| { + if let Some(v) = v { + map.insert(k, v.clone()); + } + }; + + match &self.data { + EntryData::Login { + username, + password, + totp, + uris, + } => { + ins(FieldType::Username, username); + ins(FieldType::Password, password); + ins(FieldType::Totp, totp); + if !uris.is_empty() { + ins( + FieldType::Uris, + &Some( + uris.iter() + .map(|u| u.uri.clone()) + .collect::>() + .join("\n"), + ), + ); + } + } + EntryData::Card { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + } => { + ins(FieldType::CardNumber, number); + ins(FieldType::Cvv, code); + ins(FieldType::Cardholder, cardholder_name); + ins(FieldType::Brand, brand); + ins(FieldType::ExpMonth, exp_month); + ins(FieldType::ExpYear, exp_year); + if let (Some(m), Some(y)) = (exp_month, exp_year) { + ins(FieldType::Expiration, &Some(format!("{m}/{y}"))); + } + } + EntryData::Identity { + title, + first_name, + middle_name, + last_name, + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + } => { + let name: Vec = [title, first_name, middle_name, last_name] + .iter() + .copied() + .flatten() + .cloned() + .collect(); + if !name.is_empty() { + ins(FieldType::Name, &Some(name.join(" "))); + } + + let address: Vec = [address1, address2, address3] + .iter() + .copied() + .flatten() + .cloned() + .collect(); + if !address.is_empty() { + ins(FieldType::Address, &Some(address.join("\n"))); + } + + ins(FieldType::City, city); + ins(FieldType::State, state); + ins(FieldType::PostalCode, postal_code); + ins(FieldType::Country, country); + ins(FieldType::Phone, phone); + ins(FieldType::Email, email); + ins(FieldType::Ssn, ssn); + ins(FieldType::License, license_number); + ins(FieldType::Passport, passport_number); + ins(FieldType::Username, username); + } + EntryData::SecureNote => {} + EntryData::SshKey { + private_key, + public_key, + fingerprint, + } => { + ins(FieldType::PrivateKey, private_key); + ins(FieldType::PublicKey, public_key); + ins(FieldType::Fingerprint, fingerprint); + } + } + + ins(FieldType::Notes, &self.notes); + + map + } + + pub fn custom_fields(&self) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for f in &self.fields { + if let (Some(name), Some(value)) = (&f.name, &f.value) { + map.entry(name.clone()).or_default().push(value.clone()); + } + } + map + } +} + +pub trait Decrypter { + fn decrypt_field(&mut self, entry: Option<&Entry>, field: &str) -> Result; + fn decrypt_optfield( + &mut self, + entry: Option<&Entry>, + field: &Option<&str>, + ) -> Result> { + Ok(match field { + Some(field) => Some(self.decrypt_field(entry, field)?), + None => None, + }) + } +} + +pub trait Encrypter { + fn encrypt_field(&mut self, entry: Option<&Entry>, field: &str) -> Result; + fn encrypt_optfield( + &mut self, + entry: Option<&Entry>, + field: &Option<&str>, + ) -> Result> { + Ok(match field { + Some(field) => Some(self.encrypt_field(entry, field)?), + None => None, + }) + } +} + +impl Entry { + pub fn encrypt_string(&self, s: &str, encrypter: &mut impl Encrypter) -> Result { + encrypter.encrypt_field(Some(self), s) + } + + pub fn encrypt_optstring( + &self, + optstring: &Option, + encrypter: &mut impl Encrypter, + ) -> Result> { + encrypter.encrypt_optfield(Some(self), &optstring.as_deref()) + } + + pub fn decrypt_string(&self, s: &str, decrypter: &mut impl Decrypter) -> Result { + decrypter.decrypt_field(Some(self), s) + } + + pub fn decrypt_optstring( + &self, + optstring: &Option, + decrypter: &mut impl Decrypter, + ) -> Result> { + decrypter.decrypt_optfield(Some(self), &optstring.as_deref()) + } +} + +impl Entry { + pub fn decrypt_custom_fields( + &self, + decrypter: &mut impl Decrypter, + ) -> Result> { + self.fields + .iter() + .map(|field| { + Ok(DynamicField { + name: self.decrypt_optstring(&field.name, decrypter)?, + value: self.decrypt_optstring(&field.value, decrypter)?, + ty: field.ty, + linked_id: None, // TODO: Check if None here is correct + }) + }) + .collect() + } + + pub fn decrypt_uris(&self, decrypter: &mut impl Decrypter) -> Result> { + match &self.data { + EntryData::Login { uris, .. } => Ok(uris + .iter() + .map(|u| -> Result { + Ok(Uri { + uri: decrypter.decrypt_field(Some(self), &u.uri)?, + match_type: u.match_type, + }) + }) + .collect::>>()?), + _ => Ok(vec![]), + } + } + + pub fn decrypt_history( + &self, + decrypter: &mut impl Decrypter, + ) -> Result> { + self.history + .iter() + .map(|he| { + Ok(HistoryEntry { + last_used_date: he.last_used_date.clone(), + password: decrypter.decrypt_field(Some(self), &he.password)?, + }) + }) + .collect::>() + } + + pub fn decrypt(&self, decrypter: &mut impl Decrypter) -> Result> { + // folder name should always be decrypted with the local key because + // folders are local to a specific user's vault, not the organization + let folder = + decrypter.decrypt_optfield(None::<&Entry>, &self.folder.as_deref())?; + + let fields = self.decrypt_custom_fields(decrypter)?; + + let notes = self.decrypt_optstring(&self.notes, decrypter)?; + + let history = self.decrypt_history(decrypter)?; + + let mut df = |_ft, val: &Option| self.decrypt_optstring(val, decrypter); + + let data = match &self.data { + EntryData::Login { + username, + password, + totp, + uris, + } => EntryData::Login { + username: df(FieldType::Username, username)?, + password: df(FieldType::Password, password)?, + totp: df(FieldType::Totp, totp)?, + uris: uris + .iter() + .map(|s| { + Ok(df(FieldType::Uris, &Some(s.uri.clone()))?.map(|uri| Uri { + uri, + match_type: s.match_type, + })) + }) + .collect::>>>()? + .into_iter() + .flatten() + .collect(), + }, + EntryData::Card { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + } => EntryData::Card { + cardholder_name: df(FieldType::Cardholder, cardholder_name)?, + number: df(FieldType::CardNumber, number)?, + brand: df(FieldType::Brand, brand)?, + exp_month: df(FieldType::ExpMonth, exp_month)?, + exp_year: df(FieldType::ExpYear, exp_year)?, + code: df(FieldType::Cvv, code)?, + }, + EntryData::Identity { + title, + first_name, + middle_name, + last_name, + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + } => EntryData::Identity { + title: df(FieldType::Title, title)?, + first_name: df(FieldType::FirstName, first_name)?, + middle_name: df(FieldType::MiddleName, middle_name)?, + last_name: df(FieldType::LastName, last_name)?, + address1: df(FieldType::Address1, address1)?, + address2: df(FieldType::Address2, address2)?, + address3: df(FieldType::Address3, address3)?, + city: df(FieldType::City, city)?, + state: df(FieldType::State, state)?, + postal_code: df(FieldType::PostalCode, postal_code)?, + country: df(FieldType::Country, country)?, + phone: df(FieldType::Phone, phone)?, + email: df(FieldType::Email, email)?, + ssn: df(FieldType::Ssn, ssn)?, + license_number: df(FieldType::License, license_number)?, + passport_number: df(FieldType::Passport, passport_number)?, + username: df(FieldType::Username, username)?, + }, + EntryData::SecureNote => EntryData::SecureNote {}, + EntryData::SshKey { + public_key, + fingerprint, + private_key, + } => EntryData::SshKey { + public_key: df(FieldType::PublicKey, public_key)?, + fingerprint: df(FieldType::Fingerprint, fingerprint)?, + private_key: df(FieldType::PrivateKey, private_key)?, + }, + }; + + Ok(Entry:: { + id: self.id.clone(), + folder, + folder_id: self.folder_id.clone(), + org_id: self.org_id.clone(), + key: None, + name: decrypter.decrypt_field(Some(self), &self.name)?, + data, + fields, + notes, + history, + master_password_reprompt: crate::api::CipherRepromptType::None, + _state: std::marker::PhantomData, + }) + } +} + +fn writefield( + f: &mut std::fmt::Formatter<'_>, + label: &str, + field: &Option, + displayed: &mut bool, +) -> std::fmt::Result { + if let Some(field) = field { + *displayed = true; + writeln!(f, "{label}: {field}") + } else { + Ok(()) + } +} + +/// Display impl is a bit messy as we need to support previous output format. +/// I would, for example, yank this displayed bool and always print Notes after ---. +/// I would avoid printing the "short" field this way too, but rather print it as a normal field. +impl Display for Entry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(short) = self.get_short() { + writeln!(f, "{short}")?; + } + + let mut d = false; + + match &self.data { + EntryData::Login { + username, + totp, + uris, + .. + } => { + writefield(f, "Username", username, &mut d)?; + writefield(f, "TOTP Secret", totp, &mut d)?; + + for uri in uris { + d = true; + write!(f, "{uri}")?; + } + + for field in &self.fields { + d = true; + writeln!( + f, + "{}: {}", + field.name.as_deref().unwrap_or("(null)"), + field.value.as_deref().unwrap_or("") + )?; + } + } + EntryData::Card { + cardholder_name, + brand, + exp_month, + exp_year, + code, + .. + } => { + if let (Some(m), Some(y)) = (exp_month, exp_year) { + writefield(f, "Expiration", &Some(format!("{m}/{y}")), &mut d)?; + } + + writefield(f, "CVV", code, &mut d)?; + writefield(f, "Name", cardholder_name, &mut d)?; + writefield(f, "Brand", brand, &mut d)?; + } + EntryData::Identity { + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + .. + } => { + writefield(f, "Address", address1, &mut d)?; + writefield(f, "Address", address2, &mut d)?; + writefield(f, "Address", address3, &mut d)?; + writefield(f, "City", city, &mut d)?; + writefield(f, "State", state, &mut d)?; + writefield(f, "Postcode", postal_code, &mut d)?; + writefield(f, "Country", country, &mut d)?; + writefield(f, "Phone", phone, &mut d)?; + writefield(f, "Email", email, &mut d)?; + writefield(f, "SSN", ssn, &mut d)?; + writefield(f, "License", license_number, &mut d)?; + writefield(f, "Passport", passport_number, &mut d)?; + writefield(f, "Username", username, &mut d)?; + } + EntryData::SecureNote => {} + EntryData::SshKey { fingerprint, .. } => { + writefield(f, "Fingerprint", fingerprint, &mut d)?; + + for field in &self.fields { + d = true; + writeln!( + f, + "{}: {}", + field.name.as_deref().unwrap_or("(null)"), + field.value.as_deref().unwrap_or("") + )?; + } + } + } + + if !matches!(self.data, EntryData::SecureNote) { + if let Some(notes) = &self.notes { + if d { + writeln!(f)?; + } + writeln!(f, "{notes}")?; + } + } + + Ok(()) + } +} + #[derive(serde::Serialize, Debug, Clone, Eq, PartialEq)] pub struct Uri { pub uri: String, pub match_type: Option, } +impl Display for Uri { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "URI: {}", &self.uri)?; + + if let Some(ty) = self.match_type { + writeln!(f, "Match type: {ty}")?; + } + + Ok(()) + } +} + // backwards compatibility impl<'de> serde::Deserialize<'de> for Uri { fn deserialize(deserializer: D) -> std::result::Result @@ -43,17 +809,11 @@ impl<'de> serde::Deserialize<'de> for Uri { impl<'de> serde::de::Visitor<'de> for StringOrUri { type Value = Uri; - fn expecting( - &self, - formatter: &mut std::fmt::Formatter, - ) -> std::fmt::Result { + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("uri") } - fn visit_str( - self, - value: &str, - ) -> std::result::Result + fn visit_str(self, value: &str) -> std::result::Result where E: serde::de::Error, { @@ -63,10 +823,7 @@ impl<'de> serde::Deserialize<'de> for Uri { }) } - fn visit_map( - self, - mut map: M, - ) -> std::result::Result + fn visit_map(self, mut map: M) -> std::result::Result where M: serde::de::MapAccess<'de>, { @@ -76,19 +833,13 @@ impl<'de> serde::Deserialize<'de> for Uri { match key { "uri" => { if uri.is_some() { - return Err( - serde::de::Error::duplicate_field("uri"), - ); + return Err(serde::de::Error::duplicate_field("uri")); } uri = Some(map.next_value()?); } "match_type" => { if match_type.is_some() { - return Err( - serde::de::Error::duplicate_field( - "match_type", - ), - ); + return Err(serde::de::Error::duplicate_field("match_type")); } match_type = map.next_value()?; } @@ -112,83 +863,21 @@ impl<'de> serde::Deserialize<'de> for Uri { } } -#[derive( - serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, -)] -pub enum EntryData { - Login { - username: Option, - password: Option, - totp: Option, - uris: Vec, - }, - Card { - cardholder_name: Option, - number: Option, - brand: Option, - exp_month: Option, - exp_year: Option, - code: Option, - }, - Identity { - title: Option, - first_name: Option, - middle_name: Option, - last_name: Option, - address1: Option, - address2: Option, - address3: Option, - city: Option, - state: Option, - postal_code: Option, - country: Option, - phone: Option, - email: Option, - ssn: Option, - license_number: Option, - passport_number: Option, - username: Option, - }, - SecureNote, - SshKey { - private_key: Option, - public_key: Option, - fingerprint: Option, - }, -} - -#[derive( - serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, -)] -pub struct Field { - pub ty: Option, - pub name: Option, - pub value: Option, - pub linked_id: Option, -} - -#[derive( - serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, -)] -pub struct HistoryEntry { - pub last_used_date: String, - pub password: String, -} - #[derive(serde::Serialize, serde::Deserialize, Default, Debug)] pub struct Db { + // TODO: Flatten SessionParameters into these fields pub access_token: Option, pub refresh_token: Option, - pub kdf: Option, - pub iterations: Option, - pub memory: Option, - pub parallelism: Option, + #[serde(flatten)] + pub crypto_params: Option, + pub protected_key: Option, pub protected_private_key: Option, - pub protected_org_keys: std::collections::HashMap, + pub protected_org_keys: HashMap, - pub entries: Vec, + // TODO: This could be a HashMap? + pub entries: Vec>, } impl Db { @@ -196,90 +885,97 @@ impl Db { Self::default() } - pub fn load(server: &str, email: &str) -> Result { - let file = crate::dirs::db_file(server, email); - let mut fh = - std::fs::File::open(&file).map_err(|source| Error::LoadDb { + pub async fn load_async(server: &str, email: &str) -> Result { + let file = crate::dirs::db_file(server, email)?; + let mut fh = tokio::fs::File::open(&file) + .await + .map_err(|source| Error::LoadDb { source, file: file.clone(), })?; let mut json = String::new(); fh.read_to_string(&mut json) + .await .map_err(|source| Error::LoadDb { source, file: file.clone(), })?; - let slf: Self = serde_json::from_str(&json) - .map_err(|source| Error::LoadDbJson { source, file })?; + let slf: Self = + serde_json::from_str(&json).map_err(|source| Error::LoadDbJson { source, file })?; Ok(slf) } - pub async fn load_async(server: &str, email: &str) -> Result { - let file = crate::dirs::db_file(server, email); - let mut fh = - tokio::fs::File::open(&file).await.map_err(|source| { - Error::LoadDbAsync { - source, - file: file.clone(), - } - })?; - let mut json = String::new(); - fh.read_to_string(&mut json).await.map_err(|source| { - Error::LoadDbAsync { - source, - file: file.clone(), - } - })?; - let slf: Self = serde_json::from_str(&json) - .map_err(|source| Error::LoadDbJson { source, file })?; - Ok(slf) + pub fn update_access_token(&mut self, access_token: Option) -> bool { + if let Some(access_token) = access_token { + self.access_token = Some(access_token); + true + } else { + false + } } - // XXX need to make this atomic - pub fn save(&self, server: &str, email: &str) -> Result<()> { - let file = crate::dirs::db_file(server, email); - // unwrap is safe here because Self::filename is explicitly - // constructed as a filename in a directory - std::fs::create_dir_all(file.parent().unwrap()).map_err( - |source| Error::SaveDb { - source, - file: file.clone(), - }, - )?; - let mut fh = - std::fs::File::create(&file).map_err(|source| Error::SaveDb { - source, - file: file.clone(), - })?; - fh.write_all( - serde_json::to_string(self) - .map_err(|source| Error::SaveDbJson { - source, - file: file.clone(), - })? - .as_bytes(), - ) - .map_err(|source| Error::SaveDb { source, file })?; - Ok(()) + pub fn update_refresh_token(&mut self, refresh_token: Option) -> bool { + if let Some(refresh_token) = refresh_token { + self.refresh_token = Some(refresh_token); + true + } else { + false + } + } + + pub fn apply_session_parameters(&mut self, params: &SessionParameters) { + self.access_token = Some(params.access_token.clone()); + self.refresh_token = Some(params.refresh_token.clone()); + self.crypto_params = Some(params.crypto_params.clone()); + self.protected_key = Some(params.protected_key.clone()); + } + + // TODO: Return references if possible + // NOTE: Previous error string were different. Not 100% compatible output. + pub fn get_crypto_parameters(&self) -> Result { + self.crypto_params + .clone() + .ok_or(Error::UnavailableDbCryptoParameters) + } + + // TODO: Return references if possible + pub fn get_session_parameters(&self) -> Result { + let Some(access_token) = self.access_token.clone() else { + return Err(Error::UnavailableDbSessionParameters("access_token")); + }; + + let Some(refresh_token) = self.refresh_token.clone() else { + return Err(Error::UnavailableDbSessionParameters("refresh_token")); + }; + + let Some(protected_key) = self.protected_key.clone() else { + return Err(Error::UnavailableDbSessionParameters("protected key")); + }; + + Ok(SessionParameters { + access_token, + refresh_token, + crypto_params: self.get_crypto_parameters()?, + protected_key, + }) } // XXX need to make this atomic pub async fn save_async(&self, server: &str, email: &str) -> Result<()> { - let file = crate::dirs::db_file(server, email); + let file = crate::dirs::db_file(server, email)?; // unwrap is safe here because Self::filename is explicitly // constructed as a filename in a directory tokio::fs::create_dir_all(file.parent().unwrap()) .await - .map_err(|source| Error::SaveDbAsync { + .map_err(|source| Error::SaveDb { source, file: file.clone(), })?; - let mut fh = - tokio::fs::File::create(&file).await.map_err(|source| { - Error::SaveDbAsync { - source, - file: file.clone(), - } + let mut fh = tokio::fs::File::create(&file) + .await + .map_err(|source| Error::SaveDb { + source, + file: file.clone(), })?; fh.write_all( serde_json::to_string(self) @@ -290,12 +986,12 @@ impl Db { .as_bytes(), ) .await - .map_err(|source| Error::SaveDbAsync { source, file })?; + .map_err(|source| Error::SaveDb { source, file })?; Ok(()) } pub fn remove(server: &str, email: &str) -> Result<()> { - let file = crate::dirs::db_file(server, email); + let file = crate::dirs::db_file(server, email)?; let res = std::fs::remove_file(&file); if let Err(e) = &res { if e.kind() == std::io::ErrorKind::NotFound { @@ -309,8 +1005,24 @@ impl Db { pub fn needs_login(&self) -> bool { self.access_token.is_none() || self.refresh_token.is_none() - || self.iterations.is_none() - || self.kdf.is_none() + || self.crypto_params.is_none() || self.protected_key.is_none() } + + pub fn protected_keys(&self) -> (&Option, &Option, &HashMap) { + ( + &self.protected_key, + &self.protected_private_key, + &self.protected_org_keys, + ) + } + + pub fn some_protected_keys(&self) -> Option<(&String, &String, &HashMap)> { + let keys = self.protected_keys(); + + match keys { + (Some(key), Some(priv_key), org_keys) => Some((key, priv_key, org_keys)), + _ => None, + } + } } diff --git a/src/dirs.rs b/src/dirs.rs index 079fc880..f404528a 100644 --- a/src/dirs.rs +++ b/src/dirs.rs @@ -1,19 +1,21 @@ +use directories::ProjectDirs; + use crate::prelude::*; -use std::os::unix::fs::{DirBuilderExt as _, PermissionsExt as _}; +use std::{ + os::unix::fs::{DirBuilderExt as _, PermissionsExt as _}, + path::PathBuf, +}; pub fn make_all() -> Result<()> { - create_dir_all_with_permissions(&cache_dir(), 0o700)?; - create_dir_all_with_permissions(&runtime_dir(), 0o700)?; - create_dir_all_with_permissions(&data_dir(), 0o700)?; + create_dir_all_with_permissions(&cache_dir()?, 0o700)?; + create_dir_all_with_permissions(&runtime_dir()?, 0o700)?; + create_dir_all_with_permissions(&data_dir()?, 0o700)?; Ok(()) } -fn create_dir_all_with_permissions( - path: &std::path::Path, - mode: u32, -) -> Result<()> { +fn create_dir_all_with_permissions(path: &std::path::Path, mode: u32) -> Result<()> { // ensure the initial directory creation happens with the correct mode, // to avoid race conditions std::fs::DirBuilder::new() @@ -26,73 +28,69 @@ fn create_dir_all_with_permissions( })?; // but also make sure to forcibly set the mode, in case the directory // already existed - std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) - .map_err(|source| Error::CreateDirectory { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|source| { + Error::CreateDirectory { source, file: path.to_path_buf(), - })?; + } + })?; Ok(()) } -pub fn config_file() -> std::path::PathBuf { - config_dir().join("config.json") +pub fn config_file() -> Result { + Ok(config_dir()?.join("config.json")) } const INVALID_PATH: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS.add(b'/').add(b'%').add(b':'); -pub fn db_file(server: &str, email: &str) -> std::path::PathBuf { - let server = - percent_encoding::percent_encode(server.as_bytes(), INVALID_PATH) - .to_string(); - cache_dir().join(format!("{server}:{email}.json")) + +pub fn db_file(server: &str, email: &str) -> Result { + let server = percent_encoding::percent_encode(server.as_bytes(), INVALID_PATH).to_string(); + Ok(cache_dir()?.join(format!("{server}:{email}.json"))) +} + +pub fn pid_file() -> Result { + Ok(runtime_dir()?.join("pidfile")) } -pub fn pid_file() -> std::path::PathBuf { - runtime_dir().join("pidfile") +pub fn agent_stdout_file() -> Result { + Ok(data_dir()?.join("agent.out")) } -pub fn agent_stdout_file() -> std::path::PathBuf { - data_dir().join("agent.out") +pub fn agent_stderr_file() -> Result { + Ok(data_dir()?.join("agent.err")) } -pub fn agent_stderr_file() -> std::path::PathBuf { - data_dir().join("agent.err") +pub fn device_id_file() -> Result { + Ok(data_dir()?.join("device_id")) } -pub fn device_id_file() -> std::path::PathBuf { - data_dir().join("device_id") +pub fn socket_file() -> Result { + Ok(runtime_dir()?.join("socket")) } -pub fn socket_file() -> std::path::PathBuf { - runtime_dir().join("socket") +pub fn ssh_agent_socket_file() -> Result { + Ok(runtime_dir()?.join("ssh-agent-socket")) } -pub fn ssh_agent_socket_file() -> std::path::PathBuf { - runtime_dir().join("ssh-agent-socket") +fn project_dirs() -> Result { + ProjectDirs::from("", "", &profile()).ok_or(crate::error::Error::FailedToFindDataDirectory) } -fn config_dir() -> std::path::PathBuf { - let project_dirs = - directories::ProjectDirs::from("", "", &profile()).unwrap(); - project_dirs.config_dir().to_path_buf() +fn config_dir() -> Result { + Ok(project_dirs()?.config_dir().to_path_buf()) } -fn cache_dir() -> std::path::PathBuf { - let project_dirs = - directories::ProjectDirs::from("", "", &profile()).unwrap(); - project_dirs.cache_dir().to_path_buf() +fn cache_dir() -> Result { + Ok(project_dirs()?.cache_dir().to_path_buf()) } -fn data_dir() -> std::path::PathBuf { - let project_dirs = - directories::ProjectDirs::from("", "", &profile()).unwrap(); - project_dirs.data_dir().to_path_buf() +fn data_dir() -> Result { + Ok(project_dirs()?.data_dir().to_path_buf()) } -fn runtime_dir() -> std::path::PathBuf { - let project_dirs = - directories::ProjectDirs::from("", "", &profile()).unwrap(); - project_dirs.runtime_dir().map_or_else( +fn runtime_dir() -> Result { + Ok(project_dirs()?.runtime_dir().map_or_else( || { format!( "{}/{}-{}", @@ -103,7 +101,7 @@ fn runtime_dir() -> std::path::PathBuf { .into() }, std::path::Path::to_path_buf, - ) + )) } pub fn profile() -> String { diff --git a/src/edit.rs b/src/edit.rs index 7295a93a..1393134c 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -1,96 +1,101 @@ use crate::prelude::*; -use std::io::{Read as _, Write as _}; +use std::{ + ffi::{OsStr, OsString}, + io::{IsTerminal as _, Write as _}, + path::{Path, PathBuf}, + process::Command, +}; -use is_terminal::IsTerminal as _; +fn contains_shell_metacharacters(cmd: &OsStr) -> bool { + cmd.to_str() + .is_some_and(|s| s.contains([' ', '$', '\'', '"'])) +} -pub fn edit(contents: &str, help: &str) -> Result { - if !std::io::stdin().is_terminal() { - // directly read from piped content - return match std::io::read_to_string(std::io::stdin()) { - Err(e) => Err(Error::FailedToReadFromStdin { err: e }), - Ok(res) => Ok(res), - }; +fn get_editor_metachars(editor: &OsStr, file: &Path) -> (PathBuf, Vec) { + ( + PathBuf::from("/bin/sh"), + vec![ + "-c".into(), + [editor, OsStr::new(" "), file.as_os_str()] + .into_iter() + .collect::(), + ], + ) +} + +fn get_editor_cmd_args(editor: &Path, file: &Path) -> Option<(PathBuf, Vec)> { + match editor.file_name()?.to_str() { + // disable swap files and viminfo for password entry + Some("vim" | "nvim") => Some(( + editor.to_owned(), + vec!["-ni".into(), "NONE".into(), file.into()], + )), + // other editor support welcomed + _ => Some((editor.to_owned(), vec![file.into()])), } +} +fn get_editor_cmdline(file: &Path) -> Result<(PathBuf, Vec)> { let mut var = "VISUAL"; + let editor = std::env::var_os(var).unwrap_or_else(|| { var = "EDITOR"; std::env::var_os(var).unwrap_or_else(|| "/usr/bin/vim".into()) }); - let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("rbw"); - let mut fh = std::fs::File::create(&file).unwrap(); - fh.write_all(contents.as_bytes()).unwrap(); - fh.write_all(help.as_bytes()).unwrap(); - drop(fh); - - let (cmd, args) = if contains_shell_metacharacters(&editor) { - let mut cmdline = std::ffi::OsString::new(); - cmdline.extend([ - editor.as_ref(), - std::ffi::OsStr::new(" "), - file.as_os_str(), - ]); - - let editor_args = vec![std::ffi::OsString::from("-c"), cmdline]; - (std::path::Path::new("/bin/sh"), editor_args) + if contains_shell_metacharacters(&editor) { + Ok(get_editor_metachars(&editor, file)) } else { - let editor = std::path::Path::new(&editor); - let mut editor_args = vec![]; + Ok( + get_editor_cmd_args(Path::new(&editor), file).ok_or(Error::InvalidEditor { + var: var.to_string(), + editor, + })?, + ) + } +} - #[allow(clippy::single_match_else)] // more to come - match editor.file_name() { - Some(editor) => match editor.to_str() { - Some("vim" | "nvim") => { - // disable swap files and viminfo for password entry - editor_args.push(std::ffi::OsString::from("-ni")); - editor_args.push(std::ffi::OsString::from("NONE")); - } - _ => { - // other editor support welcomed - } - }, - None => { - return Err(Error::InvalidEditor { - var: var.to_string(), - editor: editor.as_os_str().to_os_string(), - }) - } - } - editor_args.push(file.clone().into_os_string()); - (editor, editor_args) - }; +/// Small helper to avoid heap allocation of std::fs::write(.., [str1, str2].join("")) +fn write_strs(path: &Path, pieces: &[&str]) -> Result<()> { + let mut f = std::fs::File::create(path)?; + for piece in pieces { + f.write_all(piece.as_bytes())?; + } + Ok(()) +} - let res = std::process::Command::new(cmd).args(&args).status(); - match res { - Ok(res) => { - if !res.success() { - return Err(Error::FailedToRunEditor { - editor: cmd.to_owned(), - args, - res, - }); - } - } - Err(err) => { - return Err(Error::FailedToFindEditor { - editor: cmd.to_owned(), - err, - }) - } +pub fn edit(contents: &str, help: &str) -> Result { + if !std::io::stdin().is_terminal() { + // directly read from piped content + // TODO: This should be zeroized / locked as it contains sensible stuff + return std::io::read_to_string(std::io::stdin()) + .map_err(|err| Error::FailedToReadFromStdin { err }); } - let mut fh = std::fs::File::open(&file).unwrap(); - let mut contents = String::new(); - fh.read_to_string(&mut contents).unwrap(); - drop(fh); + let dir = tempfile::tempdir()?; + let file = dir.path().join("rbw"); - Ok(contents) -} + write_strs(&file, &[contents, help])?; -fn contains_shell_metacharacters(cmd: &std::ffi::OsStr) -> bool { - cmd.to_str() - .is_some_and(|s| s.contains(&[' ', '$', '\'', '"'][..])) + let (cmd, args) = get_editor_cmdline(&file)?; + + let res = Command::new(&cmd) + .args(&args) + .status() + .map_err(|err| Error::FailedToFindEditor { + editor: cmd.clone(), + err, + })?; + + if !res.success() { + return Err(Error::FailedToRunEditor { + editor: cmd, + args, + res, + }); + } + + // TODO: This should be zeroized / locked as it contains sensible stuff + Ok(std::fs::read_to_string(&file)?) } diff --git a/src/error.rs b/src/error.rs index b7789a92..81fb6004 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,5 @@ +use std::str::Utf8Error; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("email address not set")] @@ -21,9 +23,18 @@ pub enum Error { #[error("failed to create sso callback server: {err}")] CreateSSOCallbackServer { err: std::io::Error }, + #[error("failed to encrypt remotely: {message}")] + EncryptRemote { message: String }, + #[error("failed to decrypt")] Decrypt { source: block_padding::UnpadError }, + #[error("failed to decrypt remotely: {message}")] + DecryptRemote { message: String }, + + #[error("failed to find data directory")] + FailedToFindDataDirectory, + #[error("failed to find free port in {range}")] FailedToFindFreePort { range: String }, @@ -100,12 +111,6 @@ pub enum Error { file: std::path::PathBuf, }, - #[error("failed to load config from {}", .file.display())] - LoadConfigAsync { - source: tokio::io::Error, - file: std::path::PathBuf, - }, - #[error("failed to load config from {}", .file.display())] LoadConfigJson { source: serde_json::Error, @@ -118,12 +123,6 @@ pub enum Error { file: std::path::PathBuf, }, - #[error("failed to load db from {}", .file.display())] - LoadDbAsync { - source: tokio::io::Error, - file: std::path::PathBuf, - }, - #[error("failed to load db from {}", .file.display())] LoadDbJson { source: serde_json::Error, @@ -211,18 +210,21 @@ pub enum Error { file: std::path::PathBuf, }, - #[error("failed to save db to {}", .file.display())] - SaveDbAsync { - source: tokio::io::Error, - file: std::path::PathBuf, - }, - #[error("failed to save db to {}", .file.display())] SaveDbJson { source: serde_json::Error, file: std::path::PathBuf, }, + #[error("failed to find crypto parameters in db")] + UnavailableDbCryptoParameters, + + #[error("failed to find {0} in db")] + UnavailableDbSessionParameters(&'static str), + + #[error("failed to find protected keys in db")] + UnavailableDbProtectedKeys, + #[error("error spawning pinentry")] Spawn { source: tokio::io::Error }, @@ -238,11 +240,49 @@ pub enum Error { #[error("unimplemented cipherstring type: {ty}")] UnimplementedCipherStringType { ty: String }, + #[error("I/O Error: {source}")] + GenericIo { source: std::io::Error }, + #[error("error writing to pinentry stdin")] WriteStdin { source: tokio::io::Error }, #[error("invalid kdf type: {ty}")] InvalidKdfType { ty: String }, + + #[error("Utf8 conversion error: {source}")] + Utf8Error { source: Utf8Error }, + + #[error("the remote has sent an empty cipher data")] + EmptyCipherData, + + #[error("the entry has been deleted")] + DeletedEntry, +} + +impl From for Error { + fn from(value: Utf8Error) -> Self { + Self::Utf8Error { source: value } + } +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::GenericIo { source: value } + } +} + +impl From for Error { + fn from(err: reqwest::Error) -> Self { + match err.status() { + Some(status) => match status { + reqwest::StatusCode::UNAUTHORIZED => Self::RequestUnauthorized, + _ => Self::RequestFailed { + status: status.as_u16(), + }, + }, + None => Self::Reqwest { source: err }, + } + } } pub type Result = std::result::Result; diff --git a/src/identity.rs b/src/identity.rs index 96b2eecc..8cdd7ea5 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -1,4 +1,4 @@ -use crate::prelude::*; +use crate::{actions::CryptoParameters, prelude::*}; use sha1::Digest as _; @@ -12,22 +12,19 @@ impl Identity { pub fn new( email: &str, password: &crate::locked::Password, - kdf: crate::api::KdfType, - iterations: u32, - memory: Option, - parallelism: Option, + crypto_params: &CryptoParameters, ) -> Result { let email = email.trim().to_lowercase(); - let iterations = std::num::NonZeroU32::new(iterations) + let iterations = std::num::NonZeroU32::new(crypto_params.iterations) .ok_or(Error::Pbkdf2ZeroIterations)?; - let mut keys = crate::locked::Vec::new(); + let mut keys = crate::locked::LockedVec::new(); keys.extend(std::iter::repeat_n(0, 64)); - let enc_key = &mut keys.data_mut()[0..32]; + let enc_key = &mut keys[0..32]; - match kdf { + match crypto_params.kdf { crate::api::KdfType::Pbkdf2 => { pbkdf2::pbkdf2::>( password.password(), @@ -47,9 +44,9 @@ impl Identity { argon2::Algorithm::Argon2id, argon2::Version::V0x13, argon2::Params::new( - memory.unwrap() * 1024, + crypto_params.memory.unwrap() * 1024, iterations.get(), - parallelism.unwrap(), + crypto_params.parallelism.unwrap(), Some(32), ) .unwrap(), @@ -64,21 +61,15 @@ impl Identity { } } - let mut hash = crate::locked::Vec::new(); + let mut hash = crate::locked::LockedVec::new(); hash.extend(std::iter::repeat_n(0, 32)); - pbkdf2::pbkdf2::>( - enc_key, - password.password(), - 1, - hash.data_mut(), - ) - .map_err(|_| Error::Pbkdf2)?; + pbkdf2::pbkdf2::>(enc_key, password.password(), 1, &mut hash) + .map_err(|_| Error::Pbkdf2)?; - let hkdf = hkdf::Hkdf::::from_prk(enc_key) - .map_err(|_| Error::HkdfExpand)?; + let hkdf = hkdf::Hkdf::::from_prk(enc_key).map_err(|_| Error::HkdfExpand)?; hkdf.expand(b"enc", enc_key) .map_err(|_| Error::HkdfExpand)?; - let mac_key = &mut keys.data_mut()[32..64]; + let mac_key = &mut keys[32..64]; hkdf.expand(b"mac", mac_key) .map_err(|_| Error::HkdfExpand)?; diff --git a/src/json.rs b/src/json.rs index 500205c6..f1ac55d4 100644 --- a/src/json.rs +++ b/src/json.rs @@ -7,38 +7,19 @@ pub trait DeserializeJsonWithPath { impl DeserializeJsonWithPath for String { fn json_with_path(self) -> Result { let jd = &mut serde_json::Deserializer::from_str(&self); - serde_path_to_error::deserialize(jd) - .map_err(|source| Error::Json { source }) - } -} - -impl DeserializeJsonWithPath for reqwest::blocking::Response { - fn json_with_path(self) -> Result { - let bytes = - self.bytes().map_err(|source| Error::Reqwest { source })?; - let jd = &mut serde_json::Deserializer::from_slice(&bytes); - serde_path_to_error::deserialize(jd) - .map_err(|source| Error::Json { source }) + serde_path_to_error::deserialize(jd).map_err(|source| Error::Json { source }) } } pub trait DeserializeJsonWithPathAsync { #[allow(async_fn_in_trait)] - async fn json_with_path( - self, - ) -> Result; + async fn json_with_path(self) -> Result; } impl DeserializeJsonWithPathAsync for reqwest::Response { - async fn json_with_path( - self, - ) -> Result { - let bytes = self - .bytes() - .await - .map_err(|source| Error::Reqwest { source })?; + async fn json_with_path(self) -> Result { + let bytes = self.bytes().await?; let jd = &mut serde_json::Deserializer::from_slice(&bytes); - serde_path_to_error::deserialize(jd) - .map_err(|source| Error::Json { source }) + serde_path_to_error::deserialize(jd).map_err(|source| Error::Json { source }) } } diff --git a/src/lib.rs b/src/lib.rs index b0f7c788..692423b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,4 +14,5 @@ pub mod pinentry; mod prelude; pub mod protocol; pub mod pwgen; +pub mod search; pub mod wordlist; diff --git a/src/locked.rs b/src/locked.rs index ce031510..de324395 100644 --- a/src/locked.rs +++ b/src/locked.rs @@ -1,24 +1,27 @@ -use zeroize::Zeroize as _; +use std::{ + ops::{Deref, DerefMut}, + str::Utf8Error, +}; + +use zeroize::Zeroize; const LEN: usize = 4096; -static REGION_LOCK_WORKS: std::sync::OnceLock = - std::sync::OnceLock::new(); +static REGION_LOCK_WORKS: std::sync::OnceLock = std::sync::OnceLock::new(); -pub struct Vec { - data: Box>, +pub struct LockedVec { + data: Box<([u8; LEN], usize)>, _lock: Option, } -impl Default for Vec { +// TODO: Think about making the memory lock a hard requirement instead +impl Default for LockedVec { fn default() -> Self { - let data = Box::new(arrayvec::ArrayVec::<_, LEN>::new()); + let data = Box::new(([0u8; LEN], 0)); let lock = match REGION_LOCK_WORKS.get() { - Some(true) => { - Some(region::lock(data.as_ptr(), data.capacity()).unwrap()) - } + Some(true) => Some(region::lock(data.0.as_ptr(), LEN).unwrap()), Some(false) => None, - None => match region::lock(data.as_ptr(), data.capacity()) { + None => match region::lock(data.0.as_ptr(), LEN) { Ok(lock) => { let _ = REGION_LOCK_WORKS.set(true); Some(lock) @@ -35,109 +38,146 @@ impl Default for Vec { } } -impl Vec { +impl Deref for LockedVec { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + &self.data.0[0..self.data.1] + } +} + +impl DerefMut for LockedVec { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.data.0[0..self.data.1] + } +} + +impl LockedVec { pub fn new() -> Self { Self::default() } - pub fn data(&self) -> &[u8] { - self.data.as_slice() + pub fn from_slice(slice: &[u8]) -> Self { + let mut v = Self::new(); + v.extend(slice.iter().copied()); + v + } + + pub fn as_str(&self) -> Result<&str, Utf8Error> { + std::str::from_utf8(self) + } + + pub fn capacity(&self) -> usize { + LEN } - pub fn data_mut(&mut self) -> &mut [u8] { - self.data.as_mut_slice() + fn len(&self) -> usize { + self.data.1 } - pub fn zero(&mut self) { + pub fn push(&mut self, el: u8) { + let len = self.len(); + + if len == self.capacity() { + panic!("Array capacity exceeded"); + } + + self.data.0[len] = el; + self.data.1 += 1; + } + + pub fn alloc_all(&mut self) { self.truncate(0); - self.data.extend(std::iter::repeat_n(0, LEN)); + self.extend(std::iter::repeat_n(0, self.capacity())); } pub fn extend(&mut self, it: impl Iterator) { - self.data.extend(it); + for el in it { + self.push(el); + } } pub fn truncate(&mut self, len: usize) { - self.data.truncate(len); + self.data.1 = usize::min(len, self.len()); + self.data.0[self.data.1..].zeroize(); } } -impl Drop for Vec { +impl Drop for LockedVec { fn drop(&mut self) { - self.zero(); - self.data.as_mut().zeroize(); + self.data.zeroize() } } -impl Clone for Vec { +impl Clone for LockedVec { fn clone(&self) -> Self { let mut new_vec = Self::new(); - new_vec.extend(self.data().iter().copied()); + new_vec.extend(self.iter().copied()); new_vec } } #[derive(Clone)] pub struct Password { - password: Vec, + password: LockedVec, } impl Password { - pub fn new(password: Vec) -> Self { + pub fn new(password: LockedVec) -> Self { Self { password } } pub fn password(&self) -> &[u8] { - self.password.data() + &self.password } } #[derive(Clone)] pub struct Keys { - keys: Vec, + keys: LockedVec, } impl Keys { - pub fn new(keys: Vec) -> Self { + pub fn new(keys: LockedVec) -> Self { Self { keys } } pub fn enc_key(&self) -> &[u8] { - &self.keys.data()[0..32] + &self.keys[0..32] } pub fn mac_key(&self) -> &[u8] { - &self.keys.data()[32..64] + &self.keys[32..64] } } #[derive(Clone)] pub struct PasswordHash { - hash: Vec, + hash: LockedVec, } impl PasswordHash { - pub fn new(hash: Vec) -> Self { + pub fn new(hash: LockedVec) -> Self { Self { hash } } pub fn hash(&self) -> &[u8] { - self.hash.data() + &self.hash } } #[derive(Clone)] pub struct PrivateKey { - private_key: Vec, + private_key: LockedVec, } impl PrivateKey { - pub fn new(private_key: Vec) -> Self { + pub fn new(private_key: LockedVec) -> Self { Self { private_key } } pub fn private_key(&self) -> &[u8] { - self.private_key.data() + &self.private_key } } diff --git a/src/pinentry.rs b/src/pinentry.rs index ab316d72..df22ba44 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -1,182 +1,234 @@ -use crate::prelude::*; +use std::{ + ffi::{OsStr, OsString}, + process::Stdio, +}; -use std::convert::TryFrom as _; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt as _}, + process::{Child, ChildStdin, ChildStdout, Command}, +}; -use tokio::io::AsyncWriteExt as _; +use crate::{ + error::{Error, Result}, + locked::LockedVec, +}; -pub async fn getpin( - pinentry: &str, - prompt: &str, - desc: &str, - err: Option<&str>, - environment: &crate::protocol::Environment, - grab: bool, -) -> Result { - let mut opts = tokio::process::Command::new(pinentry); - opts.stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()); - let mut args = vec!["--timeout".into(), "0".into()]; - if let Some(tty) = environment.tty() { - args.extend(["--ttyname".into(), tty.into()]); - } - - let env_vars = environment.env_vars(); - // Not all pinentry appear to respect the --display flag, so we also keep the environment - // variable. - if let Some(display) = - env_vars.get(std::ffi::OsString::from("DISPLAY").as_os_str()) - { - args.extend(["--display".into(), display.clone()]); - } - if !grab { - args.push("--no-global-grab".into()); - } - opts.args(args); - - for env_var in &*crate::protocol::ENVIRONMENT_VARIABLES_OS { - if let Some(val) = env_vars.get(env_var) { - opts.env(env_var, val); +struct Pinentry { + child: Option, + reader: R, + writer: W, +} + +async fn secure_read_line(reader: &mut R) -> Result { + let mut v = LockedVec::new(); + + loop { + let b = reader.read_u8().await?; + + if b == b'\n' { + break; + } + + // NOTE: This panics if the line is > 4096 bytes + v.push(b); + } + + Ok(v) +} + +impl Pinentry { + async fn spawn( + binary: &str, + environment: &crate::protocol::Environment, + grab: bool, + ) -> Result { + let mut cmd = Command::new(binary); + + cmd.stdin(Stdio::piped()).stdout(Stdio::piped()); + + let env_vars = environment.env_vars(); + + cmd.args(Self::calc_args(environment, grab)); + + for env_var in &*crate::protocol::ENVIRONMENT_VARIABLES_OS { + if let Some(val) = env_vars.get(env_var.as_os_str()) { + cmd.env(env_var, val); + } else { + cmd.env_remove(env_var); + } + } + + cmd.envs(env_vars); + + let mut child = cmd.spawn().map_err(|source| Error::Spawn { source })?; + + let Some(stdin) = child.stdin.take() else { + return Err(Error::WriteStdin { + source: std::io::Error::other("stdin unavailable"), + }); + }; + + let Some(stdout) = child.stdout.take() else { + return Err(Error::PinentryReadOutput { + source: std::io::Error::other("stdout unavailable"), + }); + }; + + let mut p = Self { + child: Some(child), + reader: stdout, + writer: stdin, + }; + + let line = secure_read_line(&mut p.reader).await?; + + if line.as_str()?.starts_with("OK") { + Ok(p) } else { - opts.env_remove(env_var); + Err(Error::PinentryErrorMessage { + error: line.as_str()?.to_string(), + }) } } - opts.envs(env_vars); - - let mut child = opts.spawn().map_err(|source| Error::Spawn { source })?; - // unwrap is safe because we specified stdin as piped in the command opts - // above - let mut stdin = child.stdin.take().unwrap(); - - let mut ncommands = 1; - stdin - .write_all(b"SETTITLE rbw\n") - .await - .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; - stdin - .write_all(format!("SETPROMPT {prompt}\n").as_bytes()) - .await - .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; - stdin - .write_all(format!("SETDESC {desc}\n").as_bytes()) - .await - .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; - if let Some(err) = err { - stdin - .write_all(format!("SETERROR {err}\n").as_bytes()) +} + +impl Pinentry { + fn calc_args(environment: &crate::protocol::Environment, grab: bool) -> Vec { + let mut args: Vec = vec!["--timeout".into(), "0".into()]; + + if let Some(tty) = environment.tty() { + args.extend(["--ttyname".into(), tty.into()]); + } + + let env_vars = environment.env_vars(); + + // Not all pinentry appear to respect the --display flag, so we also keep the environment + // variable. + if let Some(display) = env_vars.get(OsStr::new("DISPLAY")) { + args.extend(["--display".into(), display.into()]); + } + + if !grab { + args.push("--no-global-grab".into()); + } + + args + } + + async fn command(&mut self, command: &str) -> Result { + self.writer + .write_all(format!("{command}\n").as_bytes()) .await .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; - } - stdin - .write_all(b"GETPIN\n") - .await - .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; - drop(stdin); - - let mut buf = crate::locked::Vec::new(); - buf.zero(); - // unwrap is safe because we specified stdout as piped in the command opts - // above - let len = read_password( - ncommands, - buf.data_mut(), - child.stdout.as_mut().unwrap(), - ) - .await?; - buf.truncate(len); - - child - .wait() - .await - .map_err(|source| Error::PinentryWait { source })?; - Ok(crate::locked::Password::new(buf)) -} + loop { + let mut line = secure_read_line(&mut self.reader).await?; -async fn read_password( - mut ncommands: u8, - data: &mut [u8], - mut r: R, -) -> Result -where - R: tokio::io::AsyncRead + tokio::io::AsyncReadExt + Unpin + Send, -{ - let mut len = 0; - loop { - let nl = data.iter().take(len).position(|c| *c == b'\n'); - if let Some(nl) = nl { - if data.starts_with(b"OK") { - if ncommands == 1 { - len = 0; - break; - } - data.copy_within((nl + 1).., 0); - len -= nl + 1; - ncommands -= 1; - } else if data.starts_with(b"D ") { - data.copy_within(2..nl, 0); - len = nl - 2; - break; - } else if data.starts_with(b"S ") { - data.copy_within((nl + 1).., 0); - len -= nl + 1; - } else if data.starts_with(b"ERR ") { - let line: Vec = data.iter().take(nl).copied().collect(); - let line = String::from_utf8(line).unwrap(); - let mut split = line.splitn(3, ' '); - let _ = split.next(); // ERR + let line_str = line.as_str()?; + + if line_str.starts_with("OK") { + return Ok(line); + } else if let Some(err) = line_str.strip_prefix("ERR ") { + let mut split = err.splitn(2, ' '); let code = split.next(); match code { Some("83886179") => { return Err(Error::PinentryCancelled); } - Some(code) => { - if let Some(error) = split.next() { - return Err(Error::PinentryErrorMessage { - error: error.to_string(), - }); - } + _ => { return Err(Error::PinentryErrorMessage { - error: format!("unknown error ({code})"), + error: err.to_string(), }); } - None => { + } + } else if line_str.starts_with("S ") { + continue; + } else if line_str.starts_with("D ") { + match secure_read_line(&mut self.reader).await?.as_str()? { + "OK" => { + let len = line.len(); + let len = percent_decode(&mut line[..len]); + + return Ok(LockedVec::from_slice(&line[2..len])); + } + line => { return Err(Error::PinentryErrorMessage { - error: "unknown error".to_string(), + error: line.to_string(), }); } } } else { - return Err(Error::FailedToParsePinentry { - out: String::from_utf8_lossy(data) - .trim_end_matches('\0') - .to_string(), + return Err(Error::PinentryErrorMessage { + error: line.as_str()?.to_string(), }); } - } else { - let bytes = r - .read(&mut data[len..]) + } + } + + async fn wait(mut self) -> Result<()> { + if let Some(mut child) = self.child.take() { + drop(self); + + child + .wait() .await - .map_err(|source| Error::PinentryReadOutput { source })?; - if bytes == 0 { - return Err(Error::PinentryReadOutput { - source: std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "unexpected EOF", - ), - }); - } - len += bytes; + .map_err(|source| Error::PinentryWait { source })?; } + + Ok(()) + } +} + +pub async fn getpin( + pinentry: &str, + prompt: &str, + desc: &str, + err: Option<&str>, + environment: &crate::protocol::Environment, + grab: bool, +) -> Result { + let mut pinentry = Pinentry::spawn(pinentry, environment, grab).await?; + + pinentry.command("SETTITLE rbw").await?; + pinentry.command(&format!("SETPROMPT {prompt}")).await?; + pinentry.command(&format!("SETDESC {desc}")).await?; + + if let Some(err) = err { + pinentry.command(&format!("SETERROR {err}")).await?; } - len = percent_decode(&mut data[..len]); + let buf = pinentry.command("GETPIN").await?; - Ok(len) + pinentry.wait().await?; + + Ok(crate::locked::Password::new(buf)) +} + +pub async fn confirm( + pinentry: &str, + desc: &str, + environment: &crate::protocol::Environment, + grab: bool, +) -> Result { + let mut pinentry = Pinentry::spawn(pinentry, environment, grab).await?; + + pinentry.command("SETTITLE rbw").await?; + pinentry.command(&format!("SETDESC {desc}")).await?; + + pinentry.command("CONFIRM").await?; + + pinentry.wait().await?; + + Ok(true) +} + +fn hex_digit(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'A'..=b'F' => Some(b - b'A' + 10), + b'a'..=b'f' => Some(b - b'a' + 10), + _ => None, + } } // not using the percent-encoding crate because it doesn't provide a way to do @@ -184,68 +236,143 @@ where // vec. should really move something like this into the percent-encoding crate // at some point. fn percent_decode(buf: &mut [u8]) -> usize { - let mut read_idx = 0; - let mut write_idx = 0; + let mut ri = 0; + let mut wi = 0; let len = buf.len(); - while read_idx < len { - let mut c = buf[read_idx]; - - if c == b'%' && read_idx + 2 < len { - if let Some(h) = char::from(buf[read_idx + 1]).to_digit(16) { - if let Some(l) = char::from(buf[read_idx + 2]).to_digit(16) { - // h and l were parsed from a single hex digit, so they - // must be in the range 0-15, so these unwraps are safe - c = u8::try_from(h).unwrap() * 0x10 - + u8::try_from(l).unwrap(); - read_idx += 2; + while ri < len { + let mut c = buf[ri]; + + if c == b'%' && ri + 2 < len { + if let Some(h) = hex_digit(buf[ri + 1]) { + if let Some(l) = hex_digit(buf[ri + 2]) { + c = h * 0x10 + l; + ri += 2; } } } - buf[write_idx] = c; - read_idx += 1; - write_idx += 1; + buf[wi] = c; + + ri += 1; + wi += 1; } - write_idx + wi } -#[test] -fn test_read_password() { - let good_inputs = &[ - (0, &b"D super secret password\n"[..]), - (4, &b"OK\nOK\nOK\nD super secret password\nOK\n"[..]), - (12, &b"OK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nD super secret password\nOK\n"[..]), - (24, &b"OK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nD super secret password\nOK\n"[..]), - ]; - for (ncommands, input) in good_inputs { - let mut buf = [0; 64]; - tokio::runtime::Runtime::new().unwrap().block_on(async { - let len = read_password(*ncommands, &mut buf, &input[..]) - .await - .unwrap(); - assert_eq!(&buf[0..len], b"super secret password"); - }); - } - - let match_inputs = &[ - (&b"OK\nOK\nOK\nOK\n"[..], &b""[..]), - (&b"D foo%25bar\n"[..], &b"foo%bar"[..]), - (&b"D foo%0abar\n"[..], &b"foo\nbar"[..]), - (&b"D foo%0Abar\n"[..], &b"foo\nbar"[..]), - (&b"D foo%0Gbar\n"[..], &b"foo%0Gbar"[..]), - (&b"D foo%0\n"[..], &b"foo%0"[..]), - (&b"D foo%\n"[..], &b"foo%"[..]), - (&b"D %25foo\n"[..], &b"%foo"[..]), - (&b"D %25\n"[..], &b"%"[..]), - ]; - - for (input, output) in match_inputs { - let mut buf = [0; 64]; - tokio::runtime::Runtime::new().unwrap().block_on(async { - let len = read_password(4, &mut buf, &input[..]).await.unwrap(); - assert_eq!(&buf[0..len], &output[..]); - }); +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn hex_digit_valid() { + assert_eq!(hex_digit(b'0'), Some(0)); + assert_eq!(hex_digit(b'9'), Some(9)); + assert_eq!(hex_digit(b'A'), Some(10)); + assert_eq!(hex_digit(b'F'), Some(15)); + assert_eq!(hex_digit(b'a'), Some(10)); + assert_eq!(hex_digit(b'f'), Some(15)); + } + + #[test] + fn hex_digit_invalid() { + assert_eq!(hex_digit(b'g'), None); + assert_eq!(hex_digit(b'G'), None); + assert_eq!(hex_digit(b'/'), None); + assert_eq!(hex_digit(b':'), None); + assert_eq!(hex_digit(b' '), None); + assert_eq!(hex_digit(b'%'), None); + } + + #[test] + fn percent_decode_empty() { + let mut buf = []; + assert_eq!(percent_decode(&mut buf), 0); + } + + #[test] + fn percent_decode_no_encoding() { + let mut buf = *b"hello"; + assert_eq!(percent_decode(&mut buf), 5); + assert_eq!(&buf[..5], b"hello"); + } + + #[test] + fn percent_decode_simple() { + let mut buf = *b"%20"; + assert_eq!(percent_decode(&mut buf), 1); + assert_eq!(&buf[..1], b" "); + } + + #[test] + fn percent_decode_uppercase() { + let mut buf = *b"%4A"; + assert_eq!(percent_decode(&mut buf), 1); + assert_eq!(&buf[..1], b"J"); + } + + #[test] + fn percent_decode_lowercase() { + let mut buf = *b"%4a"; + assert_eq!(percent_decode(&mut buf), 1); + assert_eq!(&buf[..1], b"J"); + } + + #[test] + fn percent_decode_mixed() { + let mut buf = *b"a%20b"; + assert_eq!(percent_decode(&mut buf), 3); + assert_eq!(&buf[..3], b"a b"); + } + + #[test] + fn percent_decode_multiple() { + let mut buf = *b"%20%21"; + assert_eq!(percent_decode(&mut buf), 2); + assert_eq!(&buf[..2], b" !"); + } + + #[test] + fn percent_decode_truncated_percent() { + let mut buf = *b"%"; + assert_eq!(percent_decode(&mut buf), 1); + assert_eq!(&buf[..1], b"%"); + } + + #[test] + fn percent_decode_truncated_pair() { + let mut buf = *b"%2"; + assert_eq!(percent_decode(&mut buf), 2); + assert_eq!(&buf[..2], b"%2"); + } + + #[test] + fn percent_decode_invalid_hex() { + let mut buf = *b"%ZZ"; + assert_eq!(percent_decode(&mut buf), 3); + assert_eq!(&buf[..3], b"%ZZ"); + } + + #[test] + fn percent_decode_invalid_second_digit() { + let mut buf = *b"%0G"; + assert_eq!(percent_decode(&mut buf), 3); + assert_eq!(&buf[..3], b"%0G"); + } + + #[test] + fn percent_decode_invalid_first_digit() { + let mut buf = *b"%G0"; + assert_eq!(percent_decode(&mut buf), 3); + assert_eq!(&buf[..3], b"%G0"); + } + + #[tokio::test] + async fn test_secure_read_line() { + let x = secure_read_line(&mut Cursor::new("ciao\n")).await.unwrap(); + assert_eq!(x.as_str().unwrap(), "ciao"); } } diff --git a/src/protocol.rs b/src/protocol.rs index ec0c06eb..1a0ffc61 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,20 +1,21 @@ -use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _}; +use std::{ + ffi::{OsStr, OsString}, + os::unix::ffi::{OsStrExt as _, OsStringExt as _}, +}; pub const VERSION: u32 = { - const fn unwrap(res: &Result) -> u32 { - match res { - Ok(t) => *t, + const fn parse_component(s: &str) -> u32 { + match u32::from_str_radix(s, 10) { + Ok(n) => n, Err(_) => panic!("failed to parse cargo version"), } } - let major = env!("CARGO_PKG_VERSION_MAJOR"); - let minor = env!("CARGO_PKG_VERSION_MINOR"); - let patch = env!("CARGO_PKG_VERSION_PATCH"); + let major = parse_component(env!("CARGO_PKG_VERSION_MAJOR")); + let minor = parse_component(env!("CARGO_PKG_VERSION_MINOR")); + let patch = parse_component(env!("CARGO_PKG_VERSION_PATCH")); - unwrap(&u32::from_str_radix(major, 10)) * 1_000_000 - + unwrap(&u32::from_str_radix(minor, 10)) * 1_000_000 - + unwrap(&u32::from_str_radix(patch, 10)) * 1_000_000 + major * 1_000_000 + minor * 1_000 + patch }; #[derive(serde::Serialize, serde::Deserialize, Debug)] @@ -73,14 +74,8 @@ pub const ENVIRONMENT_VARIABLES: &[&str] = &[ "PINENTRY_GEOM_HINT", ]; -pub static ENVIRONMENT_VARIABLES_OS: std::sync::LazyLock< - Vec, -> = std::sync::LazyLock::new(|| { - ENVIRONMENT_VARIABLES - .iter() - .map(std::ffi::OsString::from) - .collect() -}); +pub static ENVIRONMENT_VARIABLES_OS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| ENVIRONMENT_VARIABLES.iter().map(OsString::from).collect()); #[derive(Hash, PartialEq, Eq, Debug, Clone)] struct SerializableOsString(std::ffi::OsString); @@ -104,10 +99,7 @@ impl<'de> serde::Deserialize<'de> for SerializableOsString { impl serde::de::Visitor<'_> for Visitor { type Value = SerializableOsString; - fn expecting( - &self, - formatter: &mut std::fmt::Formatter, - ) -> std::fmt::Result { + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("base64 encoded os string") } @@ -116,9 +108,8 @@ impl<'de> serde::Deserialize<'de> for SerializableOsString { E: serde::de::Error, { Ok(SerializableOsString(std::ffi::OsString::from_vec( - crate::base64::decode(s).map_err(|_| { - E::invalid_value(serde::de::Unexpected::Str(s), &self) - })?, + crate::base64::decode(s) + .map_err(|_| E::invalid_value(serde::de::Unexpected::Str(s), &self))?, ))) } } @@ -142,9 +133,7 @@ impl Environment { tty: tty.map(SerializableOsString), env_vars: env_vars .into_iter() - .map(|(k, v)| { - (SerializableOsString(k), SerializableOsString(v)) - }) + .map(|(k, v)| (SerializableOsString(k), SerializableOsString(v))) .collect(), } } @@ -153,14 +142,11 @@ impl Environment { self.tty.as_ref().map(|tty| tty.0.as_os_str()) } - pub fn env_vars( - &self, - ) -> std::collections::HashMap - { + pub fn env_vars(&self) -> std::collections::HashMap<&OsStr, &OsStr> { self.env_vars .iter() - .map(|(var, val)| (var.0.clone(), val.0.clone())) - .filter(|(var, _)| (*ENVIRONMENT_VARIABLES_OS).contains(var)) + .map(|(var, val)| (var.0.as_os_str(), val.0.as_os_str())) + .filter(|(var, _)| (ENVIRONMENT_VARIABLES_OS).contains(&var.to_os_string())) .collect() } } diff --git a/src/pwgen.rs b/src/pwgen.rs index b70fbdd9..9c00d088 100644 --- a/src/pwgen.rs +++ b/src/pwgen.rs @@ -2,8 +2,7 @@ use rand::seq::IteratorRandom as _; const SYMBOLS: &[u8] = b"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; const NUMBERS: &[u8] = b"0123456789"; -const LETTERS: &[u8] = - b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const LETTERS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; const NONCONFUSABLES: &[u8] = b"34678abcdefhjkmnpqrtuwxy"; #[derive(Debug, Eq, PartialEq, Copy, Clone)] @@ -48,10 +47,7 @@ pub fn pwgen(ty: Type, len: usize) -> String { }; let mut pass = vec![]; - pass.extend( - std::iter::repeat_with(|| alphabet.iter().choose(&mut rng).unwrap()) - .take(len), - ); + pass.extend(std::iter::repeat_with(|| alphabet.iter().choose(&mut rng).unwrap()).take(len)); // unwrap is safe because the method of generating passwords guarantees // valid utf8 String::from_utf8(pass).unwrap() diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 00000000..a77d44e6 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,36 @@ +use std::{fmt::Display, str::FromStr}; + +#[derive(Debug, Clone)] +pub enum Needle { + Name(String), + Uri(url::Url), + Uuid(uuid::Uuid, String), +} + +impl Display for Needle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = match &self { + Self::Name(name) => name.clone(), + Self::Uri(uri) => uri.to_string(), + Self::Uuid(_, s) => s.clone(), + }; + write!(f, "{value}") + } +} + +impl FromStr for Needle { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + if let Ok(uuid) = uuid::Uuid::parse_str(s) { + return Ok(Needle::Uuid(uuid, s.to_string())); + } + if let Ok(url) = url::Url::parse(s) { + if url.is_special() { + return Ok(Needle::Uri(url)); + } + } + + Ok(Needle::Name(s.to_string())) + } +}