diff --git a/.gitignore b/.gitignore index 1dccbebf..e7a0c60b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /pkg +/result /target /target-ra diff --git a/Cargo.lock b/Cargo.lock index c8b0964d..1e3b8251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -13,6 +23,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -213,6 +237,17 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -252,6 +287,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -266,9 +311,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" @@ -301,6 +346,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "cipher" version = "0.4.4" @@ -455,6 +511,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -567,6 +632,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -846,6 +917,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "group" version = "0.13.0" @@ -857,6 +938,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.16.1" @@ -1109,6 +1196,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -1116,7 +1214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -1350,7 +1448,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1470,6 +1568,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -1613,7 +1717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.12.1", ] [[package]] @@ -1655,6 +1759,29 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.12.0" @@ -1744,7 +1871,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1787,9 +1914,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 +1925,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", @@ -1887,22 +2014,25 @@ dependencies = [ "pbkdf2", "percent-encoding", "pkcs8", - "rand 0.8.5", - "rand 0.9.2", + "rand 0.8.6", + "rand 0.9.4", "regex", "region", "reqwest", "rmpv", "rsa", "rustix", + "schemars", "serde", "serde_json", "serde_path_to_error", "serde_repr", + "serde_yaml", "sha1", "sha2", "signature", "ssh-agent-lib", + "ssh-key", "tempfile", "terminal_size", "textwrap", @@ -2147,9 +2277,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", @@ -2177,6 +2307,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2265,6 +2420,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.147" @@ -2312,6 +2478,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.12.1", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "service-binding" version = "3.0.0" @@ -2440,8 +2619,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", "cipher", + "ctr", + "poly1305", "ssh-encoding", + "subtle", ] [[package]] @@ -2461,6 +2647,7 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" dependencies = [ + "bcrypt-pbkdf", "ed25519-dalek", "num-bigint-dig", "p256", @@ -2811,7 +2998,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.2", + "rand 0.9.4", "rustls", "rustls-pki-types", "sha1", @@ -2844,6 +3031,22 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index d348fe04..c24da2a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,10 +64,13 @@ serde_json = "1.0.147" serde_path_to_error = "0.1.20" serde_repr = "0.1.20" serde = { version = "1.0.228", features = ["derive"] } +serde_yaml = "0.9.34" +schemars = { version = "0.8.21", features = ["preserve_order"] } sha1 = "0.10.6" sha2 = "0.10.9" signature = "2.2.0" ssh-agent-lib = "0.5.1" +ssh-key = { version = "0.6.7", features = ["encryption"] } tempfile = "3.24.0" terminal_size = "0.4.3" textwrap = "0.16.2" diff --git a/README.md b/README.md index eb9074b8..57096a72 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,29 @@ export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/rbw/ssh-agent-socket" If you're using a profile, the socket will be located at `"XDG_RUNTIME_DIR/rbw-/ssh-agent-socket"`. +### Browser Extension Integration + +`rbw` can act as the native messaging host for the official Bitwarden browser +extension. This allows the browser extension to unlock itself using +`rbw-agent`. + +To configure this, run the installation command: +```sh +rbw browser-integration install +``` +This will automatically scan for supported browsers (Firefox, Chrome, +Chromium, Brave, Vivaldi, LibreWolf, Waterfox) and register the native +messaging host. + +To clean up and remove the manifests: +```sh +rbw browser-integration uninstall +``` + +Run `rbw browser-integration install --help` for details on forcing +installation (`--force`) or specifying a custom `rbw` executable path +(`--rbw-path`). + ### 2FA support `rbw` supports the following 2FA mechanisms : diff --git a/deny.toml b/deny.toml index 2d983c06..be40d74b 100644 --- a/deny.toml +++ b/deny.toml @@ -35,14 +35,18 @@ skip = [ { name = "thiserror-impl", version = "2.0.17" }, # until the rand 0.8 -> 0.9 transition finished making its way through # the crate ecosystem - { name = "rand", version = "0.8.5" }, - { name = "rand", version = "0.9.2" }, + { name = "rand", version = "0.8.6" }, + { name = "rand", version = "0.9.4" }, { name = "rand_core", version = "0.6.4" }, { name = "rand_core", version = "0.9.3" }, { name = "rand_chacha", version = "0.3.1" }, { name = "rand_chacha", version = "0.9.0" }, { name = "getrandom", version = "0.2.16" }, { name = "getrandom", version = "0.3.4" }, + { name = "indexmap", version = "1.9.3" }, + { name = "indexmap", version = "2.12.1" }, + { name = "hashbrown", version = "0.12.3" }, + { name = "hashbrown", version = "0.16.1" }, ] [licenses] diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..5f236ab8 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1790185690, + "narHash": "sha256-xJ+X4hBtOcAFGBOe5nAMyMUeF9foJBmIOu3NjBqBycU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4975466d324710c576dc11ad614684e6bd8cad8e", + "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..c4506aea --- /dev/null +++ b/flake.nix @@ -0,0 +1,145 @@ +{ + description = "Unofficial Bitwarden CLI"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); + + cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); + + mkRbw = + { + lib, + stdenv, + rustPlatform, + openssl, + pkg-config, + installShellFiles, + bash, + # rbw-fzf + withFzf ? false, + fzf, + perl, + # rbw-rofi + withRofi ? false, + rofi, + xclip, + # pass-import + withPass ? false, + pass, + }: + rustPlatform.buildRustPackage { + pname = cargoToml.package.name; + version = cargoToml.package.version; + + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./Cargo.toml + ./Cargo.lock + ./src + ./bin + ]; + }; + + cargoLock = { + lockFile = ./Cargo.lock; + }; + + nativeBuildInputs = [ + installShellFiles + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ pkg-config ]; + + buildInputs = [ bash ]; # for git-credential-rbw + + preConfigure = lib.optionalString stdenv.hostPlatform.isLinux '' + export OPENSSL_INCLUDE_DIR="${openssl.dev}/include" + export OPENSSL_LIB_DIR="${lib.getLib openssl}/lib" + ''; + + postInstall = '' + install -Dm755 -t $out/bin bin/git-credential-rbw + '' + + lib.optionalString (stdenv.buildPlatform.canExecute stdenv.hostPlatform) '' + installShellCompletion --cmd rbw \ + --bash <($out/bin/rbw gen-completions bash) \ + --fish <($out/bin/rbw gen-completions fish) \ + --nushell <($out/bin/rbw gen-completions nushell) \ + --zsh <($out/bin/rbw gen-completions zsh) + '' + + lib.optionalString withFzf '' + install -Dm755 -t $out/bin bin/rbw-fzf + substituteInPlace $out/bin/rbw-fzf \ + --replace-fail fzf ${fzf}/bin/fzf \ + --replace-fail perl ${perl}/bin/perl + '' + + lib.optionalString withRofi '' + install -Dm755 -t $out/bin bin/rbw-rofi + substituteInPlace $out/bin/rbw-rofi \ + --replace-fail rofi ${rofi}/bin/rofi \ + --replace-fail xclip ${xclip}/bin/xclip + '' + + lib.optionalString withPass '' + install -Dm755 -t $out/bin bin/pass-import + substituteInPlace $out/bin/pass-import \ + --replace-fail pass ${pass}/bin/pass + ''; + + meta = { + description = "Unofficial command line client for Bitwarden"; + homepage = "https://crates.io/crates/rbw"; + license = lib.licenses.mit; + mainProgram = "rbw"; + }; + }; + in + { + packages = forAllSystems ( + system: + let + pkgs = nixpkgsFor.${system}; + rbw = pkgs.callPackage mkRbw { }; + in + { + inherit rbw; + default = rbw; + } + ); + + overlays.default = final: _prev: { + rbw = final.callPackage mkRbw { }; + }; + + devShells = forAllSystems ( + system: + let + pkgs = nixpkgsFor.${system}; + in + { + default = pkgs.mkShell { + inputsFrom = [ self.packages.${system}.default ]; + packages = with pkgs; [ + cargo + rustc + rustfmt + clippy + rust-analyzer + ]; + }; + } + ); + }; +} diff --git a/src/actions.rs b/src/actions.rs index 79d304d4..310bbf50 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -112,21 +112,17 @@ pub fn unlock( 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 private_key = crate::locked::PrivateKey::new( + protected_private_key.decrypt_locked_symmetric(&key)?, + ); 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 org_key = crate::locked::Keys::new( + protected_org_key.decrypt_locked_asymmetric(&private_key)?, + ); org_keys.insert(org_id.clone(), org_key); } @@ -171,28 +167,53 @@ async fn sync_once( pub fn add( access_token: &str, refresh_token: &str, + org_id: Option<&str>, name: &str, data: &crate::db::EntryData, + fields: &[crate::db::Field], notes: Option<&str>, folder_id: Option<&str>, + history: &[crate::db::HistoryEntry], ) -> Result<(Option, ())> { with_exchange_refresh_token(access_token, refresh_token, |access_token| { - add_once(access_token, name, data, notes, folder_id) + add_once( + access_token, + org_id, + name, + data, + fields, + notes, + folder_id, + history, + ) }) } fn add_once( access_token: &str, + org_id: Option<&str>, name: &str, data: &crate::db::EntryData, + fields: &[crate::db::Field], notes: Option<&str>, folder_id: Option<&str>, + history: &[crate::db::HistoryEntry], ) -> Result<()> { let (client, _) = api_client()?; - client.add(access_token, name, data, notes, folder_id)?; + client.add( + access_token, + org_id, + name, + data, + fields, + notes, + folder_id, + history, + )?; Ok(()) } +#[allow(clippy::too_many_arguments)] pub fn edit( access_token: &str, refresh_token: &str, @@ -204,6 +225,7 @@ pub fn edit( notes: Option<&str>, folder_uuid: Option<&str>, history: &[crate::db::HistoryEntry], + key: Option<&str>, ) -> Result<(Option, ())> { with_exchange_refresh_token(access_token, refresh_token, |access_token| { edit_once( @@ -216,10 +238,12 @@ pub fn edit( notes, folder_uuid, history, + key, ) }) } +#[allow(clippy::too_many_arguments)] fn edit_once( access_token: &str, id: &str, @@ -230,6 +254,7 @@ fn edit_once( notes: Option<&str>, folder_uuid: Option<&str>, history: &[crate::db::HistoryEntry], + key: Option<&str>, ) -> Result<()> { let (client, _) = api_client()?; client.edit( @@ -242,6 +267,7 @@ fn edit_once( notes, folder_uuid, history, + key, )?; Ok(()) } diff --git a/src/api.rs b/src/api.rs index a817fb26..c01937b0 100644 --- a/src/api.rs +++ b/src/api.rs @@ -750,9 +750,11 @@ struct SyncResPasswordHistory { #[derive(serde::Serialize, Debug)] struct CiphersPostReq { #[serde(rename = "type")] - ty: u32, // XXX what are the valid types? + ty: u32, #[serde(rename = "folderId")] folder_id: Option, + #[serde(rename = "organizationId")] + organization_id: Option, name: String, notes: Option, login: Option, @@ -760,12 +762,17 @@ struct CiphersPostReq { identity: Option, #[serde(rename = "secureNote")] secure_note: Option, + #[serde(rename = "sshKey")] + ssh_key: Option, + fields: Vec, + #[serde(rename = "passwordHistory")] + password_history: Vec, } #[derive(serde::Serialize, Debug)] struct CiphersPutReq { #[serde(rename = "type")] - ty: u32, // XXX what are the valid types? + ty: u32, #[serde(rename = "folderId")] folder_id: Option, #[serde(rename = "organizationId")] @@ -778,8 +785,12 @@ struct CiphersPutReq { fields: Vec, #[serde(rename = "secureNote")] secure_note: Option, + #[serde(rename = "sshKey")] + ssh_key: Option, #[serde(rename = "passwordHistory")] password_history: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + key: Option, } #[derive(serde::Serialize, Debug)] @@ -1197,20 +1208,47 @@ impl Client { pub fn add( &self, access_token: &str, + org_id: Option<&str>, name: &str, data: &crate::db::EntryData, + fields: &[crate::db::Field], notes: Option<&str>, folder_id: Option<&str>, + history: &[crate::db::HistoryEntry], ) -> Result<()> { let mut req = CiphersPostReq { - ty: 1, + 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 { .. } => 5, + }, folder_id: folder_id.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, + ssh_key: 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 { @@ -1297,7 +1335,17 @@ impl Client { crate::db::EntryData::SecureNote => { req.secure_note = Some(CipherSecureNote {}); } - crate::db::EntryData::SshKey { .. } => unreachable!(), + crate::db::EntryData::SshKey { + private_key, + public_key, + fingerprint, + } => { + req.ssh_key = Some(CipherSshKey { + private_key: private_key.clone(), + public_key: public_key.clone(), + fingerprint: fingerprint.clone(), + }); + } } let client = reqwest::blocking::Client::new(); let res = client @@ -1317,6 +1365,7 @@ impl Client { } } + #[allow(clippy::too_many_arguments)] pub fn edit( &self, access_token: &str, @@ -1328,6 +1377,7 @@ impl Client { notes: Option<&str>, folder_uuid: Option<&str>, history: &[crate::db::HistoryEntry], + key: Option<&str>, ) -> Result<()> { let mut req = CiphersPutReq { ty: match data { @@ -1335,7 +1385,7 @@ impl Client { crate::db::EntryData::SecureNote => 2, crate::db::EntryData::Card { .. } => 3, crate::db::EntryData::Identity { .. } => 4, - crate::db::EntryData::SshKey { .. } => unreachable!(), + crate::db::EntryData::SshKey { .. } => 5, }, folder_id: folder_uuid.map(std::string::ToString::to_string), organization_id: org_id.map(std::string::ToString::to_string), @@ -1345,6 +1395,7 @@ impl Client { card: None, identity: None, secure_note: None, + ssh_key: None, fields: fields .iter() .map(|field| CipherField { @@ -1361,6 +1412,7 @@ impl Client { password: entry.password.clone(), }) .collect(), + key: key.map(std::string::ToString::to_string), }; match data { crate::db::EntryData::Login { @@ -1447,7 +1499,17 @@ impl Client { crate::db::EntryData::SecureNote => { req.secure_note = Some(CipherSecureNote {}); } - crate::db::EntryData::SshKey { .. } => unreachable!(), + crate::db::EntryData::SshKey { + private_key, + public_key, + fingerprint, + } => { + req.ssh_key = Some(CipherSshKey { + private_key: private_key.clone(), + public_key: public_key.clone(), + fingerprint: fingerprint.clone(), + }); + } } let client = reqwest::blocking::Client::new(); let res = client @@ -1738,24 +1800,20 @@ fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { "invalid_client" => { return 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 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" => { + 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 }; } - s => { - if s.starts_with( - "Invalid TOTP code! Server time: ", - ) { - return Error::IncorrectPassword { message }; - } - } } } } diff --git a/src/base64.rs b/src/base64.rs index 86971bc8..82b32e54 100644 --- a/src/base64.rs +++ b/src/base64.rs @@ -13,3 +13,9 @@ pub fn decode>( ) -> Result, base64::DecodeError> { base64::engine::general_purpose::STANDARD.decode(input) } + +pub fn decode_url_safe_no_pad>( + input: T, +) -> Result, base64::DecodeError> { + base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(input) +} diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 9ddd2ad9..1537df8e 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -692,6 +692,7 @@ pub async fn encrypt( sock: &mut crate::sock::Sock, state: std::sync::Arc>, plaintext: &str, + entry_key: Option<&str>, org_id: Option<&str>, ) -> anyhow::Result<()> { let state = state.lock().await; @@ -700,8 +701,20 @@ pub async fn encrypt( "failed to find encryption 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 cipherstring = rbw::cipherstring::CipherString::encrypt_symmetric( - keys, + entry_key.as_ref().unwrap_or(keys), plaintext.as_bytes(), ) .context("failed to encrypt plaintext secret")?; @@ -950,3 +963,32 @@ pub async fn find_ssh_private_key( Err(anyhow::anyhow!("No matching private key found")) } + +pub async fn get_master_key( + sock: &mut crate::sock::Sock, + state: std::sync::Arc>, + environment: &rbw::protocol::Environment, +) -> anyhow::Result<()> { + use zeroize::Zeroize as _; + + unlock_state(state.clone(), environment).await?; + + let state = state.lock().await; + let keys = state.priv_key.as_ref().context("agent is locked")?; + + let mut combined_keys = Vec::with_capacity(64); + combined_keys.extend_from_slice(keys.enc_key()); + combined_keys.extend_from_slice(keys.mac_key()); + + let key_b64 = rbw::base64::encode(&combined_keys); + combined_keys.zeroize(); + + let mut resp = rbw::protocol::Response::MasterKey { key: key_b64 }; + let res = sock.send(&resp).await; + if let rbw::protocol::Response::MasterKey { ref mut key } = resp { + key.zeroize(); + } + res?; + + Ok(()) +} diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 1691ed51..62bf18bc 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -162,11 +162,16 @@ async fn handle_request( .await?; true } - rbw::protocol::Action::Encrypt { plaintext, org_id } => { + rbw::protocol::Action::Encrypt { + plaintext, + entry_key, + org_id, + } => { crate::actions::encrypt( sock, state.clone(), plaintext, + entry_key.as_deref(), org_id.as_deref(), ) .await?; @@ -177,6 +182,11 @@ async fn handle_request( .await?; true } + rbw::protocol::Action::GetMasterKey => { + crate::actions::get_master_key(sock, state.clone(), &environment) + .await?; + true + } rbw::protocol::Action::Quit => std::process::exit(0), rbw::protocol::Action::Version => { crate::actions::version(sock).await?; diff --git a/src/bin/rbw/actions.rs b/src/bin/rbw/actions.rs index a0a34e8c..0c5b51dd 100644 --- a/src/bin/rbw/actions.rs +++ b/src/bin/rbw/actions.rs @@ -107,6 +107,7 @@ pub fn decrypt( pub fn encrypt( plaintext: &str, + entry_key: Option<&str>, org_id: Option<&str>, ) -> anyhow::Result { let mut sock = connect()?; @@ -114,6 +115,7 @@ pub fn encrypt( get_environment(), rbw::protocol::Action::Encrypt { plaintext: plaintext.to_string(), + entry_key: entry_key.map(std::string::ToString::to_string), org_id: org_id.map(std::string::ToString::to_string), }, ))?; @@ -166,7 +168,7 @@ fn simple_action(action: rbw::protocol::Action) -> anyhow::Result<()> { } } -fn connect() -> anyhow::Result { +pub fn connect() -> anyhow::Result { crate::sock::Sock::connect().with_context(|| { let log = rbw::dirs::agent_stderr_file(); format!( @@ -187,7 +189,7 @@ fn wait_for_exit(pid: rustix::process::Pid) { } } -fn get_environment() -> rbw::protocol::Environment { +pub fn get_environment() -> rbw::protocol::Environment { let tty = std::env::var_os("RBW_TTY").or_else(|| { rustix::termios::ttyname(std::io::stdin(), vec![]) .ok() diff --git a/src/bin/rbw/browser.rs b/src/bin/rbw/browser.rs new file mode 100644 index 00000000..1a2194c3 --- /dev/null +++ b/src/bin/rbw/browser.rs @@ -0,0 +1,861 @@ +use anyhow::{anyhow, Context as _, Result}; +use serde::{Deserialize, Serialize, Serializer}; +use serde_json::{from_slice, from_value, json, to_vec, Value}; +use std::fs; +use std::io::{self, BufReader, Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use aes::Aes256; +use cbc::cipher::{ + block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit, +}; +use hmac::{Hmac, Mac}; +use rand::Rng as _; +use rand_8 as rand; +use rsa::{pkcs8::DecodePublicKey, Oaep, RsaPublicKey}; +use sha1::Sha1; +use sha2::Sha256; + +#[derive( + Copy, + Clone, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, + clap::ValueEnum, +)] +#[serde(rename_all = "lowercase")] +pub enum Browser { + Firefox, + Chrome, + Chromium, + Brave, + Vivaldi, + #[value(name = "librewolf")] + LibreWolf, + Waterfox, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncString { + #[serde(rename = "encryptionType")] + enc_type: i32, + data: String, + iv: String, + mac: String, +} + +impl EncString { + pub fn new(data: &[u8], iv: &[u8; 16], mac: &[u8; 32]) -> Self { + Self { + enc_type: 2, + data: rbw::base64::encode(data), + iv: rbw::base64::encode(iv), + mac: rbw::base64::encode(mac), + } + } + + pub fn data(&self) -> Result> { + rbw::base64::decode(&self.data) + .map_err(|e| anyhow!("failed to decode data: {e:?}")) + } + + pub fn iv(&self) -> Result> { + rbw::base64::decode(&self.iv) + .map_err(|e| anyhow!("failed to decode iv: {e:?}")) + } + + pub fn mac(&self) -> Result> { + rbw::base64::decode(&self.mac) + .map_err(|e| anyhow!("failed to decode mac: {e:?}")) + } +} + +impl std::fmt::Display for EncString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}.{}|{}|{}", + self.enc_type, self.iv, self.data, self.mac + ) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct EncryptedMessage { + command: String, + #[serde(rename = "messageId")] + message_id: i64, + #[serde(rename = "userId")] + user_id: Option, +} + +#[derive(Debug, Clone)] +pub enum ResponseData { + Number(i32), + Bool(bool), +} + +impl Serialize for ResponseData { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Number(n) => serializer.serialize_i32(*n), + Self::Bool(b) => serializer.serialize_bool(*b), + } + } +} + +impl From for ResponseData { + fn from(n: i32) -> Self { + Self::Number(n) + } +} + +impl From for ResponseData { + fn from(b: bool) -> Self { + Self::Bool(b) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ResponseMessage { + timestamp: u64, + command: String, + #[serde(rename = "messageId")] + message_id: i64, + response: ResponseData, + #[serde(rename = "userKeyB64")] + key: Option, +} + +impl ResponseMessage { + pub fn new>( + command: &str, + message_id: i64, + response: T, + ) -> Self { + Self::with_key(command, message_id, response, None) + } + + pub fn with_key>( + command: &str, + message_id: i64, + response: T, + key: Option, + ) -> Self { + Self { + timestamp: u64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + ) + .unwrap_or(u64::MAX), + command: command.to_string(), + message_id, + response: response.into(), + key, + } + } +} + +impl Drop for ResponseMessage { + fn drop(&mut self) { + use zeroize::Zeroize as _; + if let Some(key) = &mut self.key { + key.zeroize(); + } + } +} + +struct Aes256CbcHmacKey { + enc_key: [u8; 32], + mac_key: [u8; 32], +} + +impl Drop for Aes256CbcHmacKey { + fn drop(&mut self) { + use zeroize::Zeroize as _; + self.enc_key.zeroize(); + self.mac_key.zeroize(); + } +} + +impl Aes256CbcHmacKey { + fn new() -> Self { + let mut rng = rand::thread_rng(); + let enc_key = rng.gen(); + let mac_key = rng.gen(); + Self { enc_key, mac_key } + } + + fn to_vec(&self) -> Vec { + let mut key_vec = Vec::with_capacity(64); + key_vec.extend_from_slice(&self.enc_key); + key_vec.extend_from_slice(&self.mac_key); + key_vec + } + + fn decrypt(&self, iv: &[u8], mac: &[u8], data: &[u8]) -> Result> { + // Verify MAC + let mut hmac = Hmac::::new_from_slice(&self.mac_key)?; + hmac.update(iv); + hmac.update(data); + hmac.verify_slice(mac) + .map_err(|_| anyhow!("MAC verification failed"))?; + + // Decrypt + let decryptor = + cbc::Decryptor::::new_from_slices(&self.enc_key, iv) + .map_err(|e| anyhow!("invalid key or IV length: {e}"))?; + let decrypted = decryptor + .decrypt_padded_vec_mut::(data) + .map_err(|e| anyhow!("AES decrypt error: {e:?}"))?; + Ok(decrypted) + } + + fn encrypt(&self, msg: &[u8]) -> Result { + let iv = rand::thread_rng().gen::<[u8; 16]>(); + let encryptor = + cbc::Encryptor::::new(&self.enc_key.into(), &iv.into()); + let data = encryptor.encrypt_padded_vec_mut::(msg); + + // Generate MAC + let mut hmac = Hmac::::new_from_slice(&self.mac_key)?; + hmac.update(&iv); + hmac.update(&data); + let mac: [u8; 32] = hmac.finalize().into_bytes().into(); + + Ok(EncString::new(&data, &iv, &mac)) + } +} + +pub fn rsa_encrypt(public_key_b64: &str, message: &[u8]) -> Result { + let public_key = rbw::base64::decode(public_key_b64) + .map_err(|e| anyhow!("failed to decode public key: {e:?}"))?; + let public_key = RsaPublicKey::from_public_key_der(&public_key)?; + let mut rng = rand::thread_rng(); + let padding = Oaep::new::(); + let ct = public_key.encrypt(&mut rng, padding, message)?; + Ok(rbw::base64::encode(&ct)) +} + +const MAX_MESSAGE_SIZE: u32 = 16 * 1024 * 1024; + +pub fn host() -> Result<()> { + use zeroize::Zeroize as _; + + let mut session_key: Option = None; + let mut r = BufReader::new(io::stdin()); + + send_msg(&json!({ + "command": "connected", + "app_id": "com.8bit.bitwarden" + }))?; + + while let Some(msg_buf) = read_message(&mut r)? { + let msg = from_slice::(&msg_buf)?; + let app_id = msg + .get("appId") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("Missing 'appId' field"))?; + + let message_field = msg + .get("message") + .ok_or_else(|| anyhow!("Missing 'message' field"))?; + + if let Some(command) = + message_field.get("command").and_then(Value::as_str) + { + if command == "setupEncryption" { + let public_key_b64 = message_field + .get("publicKey") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("Missing 'publicKey' field"))?; + + let key = Aes256CbcHmacKey::new(); + let mut raw_key = key.to_vec(); + let encrypted_secret_res = + rsa_encrypt(public_key_b64, &raw_key); + raw_key.zeroize(); + let encrypted_secret = encrypted_secret_res?; + + session_key = Some(key); + + send_msg(&json!({ + "command": "setupEncryption", + "appId": app_id, + "sharedSecret": encrypted_secret + }))?; + continue; + } + } + + // Encrypted message + let key = session_key + .as_ref() + .ok_or_else(|| anyhow!("Session not initialized"))?; + let enc_str: EncString = from_value(message_field.clone())?; + if enc_str.enc_type != 2 { + anyhow::bail!("unsupported encryption type: {}", enc_str.enc_type); + } + + let mut decrypted_bytes = + key.decrypt(&enc_str.iv()?, &enc_str.mac()?, &enc_str.data()?)?; + + let decrypted_msg_res = + from_slice::(&decrypted_bytes); + decrypted_bytes.zeroize(); + let decrypted_msg = decrypted_msg_res?; + + let response_message = handle_decrypted_message(&decrypted_msg)?; + + // Encrypt the response message + let mut serialized_response = to_vec(&response_message)?; + let encrypted_response_res = key.encrypt(&serialized_response); + serialized_response.zeroize(); + let encrypted_response_str = encrypted_response_res?; + + send_msg(&json!({ + "appId": app_id, + "messageId": decrypted_msg.message_id, + "message": { + "encryptedString": encrypted_response_str.to_string() + } + }))?; + } + + Ok(()) +} + +fn send_msg(msg: &Value) -> Result<()> { + let serialized = to_vec(msg)?; + io::stdout() + .write_all(&u32::try_from(serialized.len()).unwrap().to_ne_bytes())?; + io::stdout().write_all(&serialized)?; + io::stdout().flush()?; + Ok(()) +} + +fn read_message(reader: &mut R) -> Result>> { + let mut len_buf = [0u8; 4]; + let mut read_len = 0; + while read_len < 4 { + match reader.read(&mut len_buf[read_len..]) { + Ok(0) if read_len == 0 => return Ok(None), // Clean EOF + Ok(0) => { + return Err( + io::Error::from(io::ErrorKind::UnexpectedEof).into() + ) + } + Ok(n) => read_len += n, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e.into()), + } + } + let len = u32::from_ne_bytes(len_buf); + if len > MAX_MESSAGE_SIZE { + anyhow::bail!("message size {len} exceeds maximum allowed size"); + } + let mut msg_buf = vec![0u8; usize::try_from(len)?]; + reader.read_exact(&mut msg_buf)?; + Ok(Some(msg_buf)) +} + +fn extract_jwt_sub(token: &str) -> Option { + let payload_b64 = token.split('.').nth(1)?; + let payload_bytes = rbw::base64::decode_url_safe_no_pad(payload_b64).ok()?; + let payload: Value = serde_json::from_slice(&payload_bytes).ok()?; + payload.get("sub")?.as_str().map(str::to_string) +} + +fn db_matches_user(user_id: Option<&str>) -> bool { + let Ok(config) = rbw::config::Config::load() else { + return false; + }; + let Some(email) = config.email.as_ref() else { + return false; + }; + let server = config.server_name(); + if !rbw::dirs::db_file(&server, email).exists() { + return false; + } + if let Some(expected_user_id) = user_id.filter(|s| !s.is_empty()) { + if let Ok(db) = rbw::db::Db::load(&server, email) { + if let Some(sub) = + db.access_token.as_deref().and_then(extract_jwt_sub) + { + if !sub.eq_ignore_ascii_case(expected_user_id) { + return false; + } + } + } + } + true +} + +fn handle_decrypted_message( + msg: &EncryptedMessage, +) -> Result { + match msg.command.as_str() { + "getBiometricsStatus" => { + Ok(ResponseMessage::new( + "getBiometricsStatus", + msg.message_id, + true, // biometrics available + )) + } + "authenticateWithBiometrics" => Ok(ResponseMessage::new( + "authenticateWithBiometrics", + msg.message_id, + true, + )), + "getBiometricsStatusForUser" => { + let available = db_matches_user(msg.user_id.as_deref()); + + Ok(ResponseMessage::new( + "getBiometricsStatusForUser", + msg.message_id, + if available { 0 } else { 4 }, // 0 = Available, 4 = Not Configured + )) + } + "unlockWithBiometricsForUser" => { + if !db_matches_user(msg.user_id.as_deref()) { + return Ok(ResponseMessage::new( + "unlockWithBiometricsForUser", + msg.message_id, + false, + )); + } + get_master_key_from_agent().map_or_else( + |e| { + log::warn!( + "failed to unlock with master key from agent: {e:#}" + ); + Ok(ResponseMessage::new( + "unlockWithBiometricsForUser", + msg.message_id, + false, + )) + }, + |key| { + Ok(ResponseMessage::with_key( + "unlockWithBiometricsForUser", + msg.message_id, + true, + Some(key), + )) + }, + ) + } + _ => Err(anyhow!("unsupported command: {cmd}", cmd = msg.command)), + } +} + +fn get_master_key_from_agent() -> Result { + crate::commands::unlock()?; + + let mut sock = crate::actions::connect()?; + let environment = crate::actions::get_environment(); + + let request = rbw::protocol::Request::new( + environment, + rbw::protocol::Action::GetMasterKey, + ); + + sock.send(&request)?; + match sock.recv()? { + rbw::protocol::Response::MasterKey { key } => Ok(key), + rbw::protocol::Response::Error { error } => { + Err(anyhow!("agent error: {error}")) + } + r => Err(anyhow!("unexpected response from agent: {r:?}")), + } +} + +struct BrowserConfig { + browser: Browser, + name: &'static str, + manifest_dir: PathBuf, + is_firefox: bool, +} + +fn get_browsers() -> Result> { + let base_dirs = directories::BaseDirs::new() + .context("failed to find home directory")?; + let home = base_dirs.home_dir(); + let mut browsers = Vec::new(); + + #[cfg(target_os = "linux")] + { + browsers.push(BrowserConfig { + browser: Browser::Firefox, + name: "firefox", + manifest_dir: home.join(".mozilla/native-messaging-hosts"), + is_firefox: true, + }); + browsers.push(BrowserConfig { + browser: Browser::Chrome, + name: "chrome", + manifest_dir: home + .join(".config/google-chrome/NativeMessagingHosts"), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::Chromium, + name: "chromium", + manifest_dir: home.join(".config/chromium/NativeMessagingHosts"), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::Brave, + name: "brave", + manifest_dir: home.join( + ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts", + ), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::Vivaldi, + name: "vivaldi", + manifest_dir: home.join(".config/vivaldi/NativeMessagingHosts"), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::LibreWolf, + name: "librewolf", + manifest_dir: home.join(".librewolf/native-messaging-hosts"), + is_firefox: true, + }); + browsers.push(BrowserConfig { + browser: Browser::Waterfox, + name: "waterfox", + manifest_dir: home.join(".waterfox/native-messaging-hosts"), + is_firefox: true, + }); + } + + #[cfg(target_os = "macos")] + { + browsers.push(BrowserConfig { + browser: Browser::Firefox, + name: "firefox", + manifest_dir: home.join( + "Library/Application Support/Mozilla/NativeMessagingHosts", + ), + is_firefox: true, + }); + browsers.push(BrowserConfig { + browser: Browser::Chrome, + name: "chrome", + manifest_dir: home.join("Library/Application Support/Google/Chrome/NativeMessagingHosts"), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::Chromium, + name: "chromium", + manifest_dir: home.join( + "Library/Application Support/Chromium/NativeMessagingHosts", + ), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::Brave, + name: "brave", + manifest_dir: home.join("Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts"), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::Vivaldi, + name: "vivaldi", + manifest_dir: home.join( + "Library/Application Support/Vivaldi/NativeMessagingHosts", + ), + is_firefox: false, + }); + browsers.push(BrowserConfig { + browser: Browser::LibreWolf, + name: "librewolf", + manifest_dir: home.join( + "Library/Application Support/LibreWolf/NativeMessagingHosts", + ), + is_firefox: true, + }); + browsers.push(BrowserConfig { + browser: Browser::Waterfox, + name: "waterfox", + manifest_dir: home.join( + "Library/Application Support/Waterfox/NativeMessagingHosts", + ), + is_firefox: true, + }); + } + + Ok(browsers) +} + +fn get_target_browsers( + browser: Option, +) -> Result> { + let all_browsers = get_browsers()?; + if let Some(b) = browser { + Ok(all_browsers + .into_iter() + .filter(|bc| bc.browser == b) + .collect()) + } else { + Ok(all_browsers + .into_iter() + .filter(|bc| { + let config_root = bc.manifest_dir.parent().unwrap(); + config_root.exists() || bc.manifest_dir.exists() + }) + .collect()) + } +} + +fn normalize_lexical(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in path.components() { + match comp { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + +fn resolve_without_symlinks(path: &Path) -> Result { + let abs = std::path::absolute(path)?; + Ok(normalize_lexical(&abs).to_string_lossy().into_owned()) +} + +fn resolve_in_path(cmd: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(cmd); + if candidate.is_file() { + if let Ok(resolved) = resolve_without_symlinks(&candidate) { + return Some(resolved); + } + } + } + None +} + +fn resolve_executable_path(rbw_path: Option<&str>) -> Result { + if let Some(path) = rbw_path { + let path_obj = Path::new(path); + if path_obj.components().count() > 1 { + resolve_without_symlinks(path_obj) + .context("failed to resolve explicit rbw path") + } else { + Ok(path.to_string()) + } + } else { + let argv0 = + std::env::args().next().context("failed to get argv[0]")?; + let argv0_path = Path::new(&argv0); + if argv0_path.components().count() > 1 { + resolve_without_symlinks(argv0_path) + .context("failed to resolve current executable path") + } else { + Ok(resolve_in_path(&argv0).unwrap_or(argv0)) + } + } +} + +pub fn install( + browser: Option, + force: bool, + rbw_path: Option<&str>, +) -> Result<()> { + let resolved_path = resolve_executable_path(rbw_path)?; + + println!("Using rbw executable path: {resolved_path}"); + + let target_browsers = get_target_browsers(browser)?; + + if target_browsers.is_empty() { + println!("No installed browsers detected."); + return Ok(()); + } + + for bc in target_browsers { + println!("Installing integration for {}...", bc.name); + + fs::create_dir_all(&bc.manifest_dir).context(format!( + "failed to create manifest directory for {}", + bc.name + ))?; + + let manifest_file = bc.manifest_dir.join("com.8bit.bitwarden.json"); + let wrapper_file = bc.manifest_dir.join("rbw-browser-bridge"); + + if manifest_file.exists() && !force { + let existing_manifest = fs::read_to_string(&manifest_file) + .context("failed to read existing manifest")?; + if !existing_manifest.contains("rbw-browser-bridge") { + anyhow::bail!( + "A native messaging manifest already exists for {name} and does not seem to belong to rbw.\n\ + Use --force to overwrite it.", + name = bc.name + ); + } + } + + let escaped_path = resolved_path.replace('\'', "'\\''"); + let wrapper_content = format!( + "#!/bin/sh\nexec '{escaped_path}' browser-integration host \"$@\"\n" + ); + fs::write(&wrapper_file, wrapper_content) + .context("failed to write wrapper script")?; + + let mut perms = fs::metadata(&wrapper_file)?.permissions(); + perms.set_mode(perms.mode() | 0o111); + fs::set_permissions(&wrapper_file, perms)?; + + let manifest_json = if bc.is_firefox { + json!({ + "name": "com.8bit.bitwarden", + "description": "Bitwarden desktop <-> browser bridge (rbw)", + "path": wrapper_file.to_string_lossy(), + "type": "stdio", + "allowed_extensions": [ + "{446900e4-71c2-419f-a6a7-df9c091e268b}" + ] + }) + } else { + json!({ + "name": "com.8bit.bitwarden", + "description": "Bitwarden desktop <-> browser bridge (rbw)", + "path": wrapper_file.to_string_lossy(), + "type": "stdio", + "allowed_origins": [ + "chrome-extension://nngcebflmdojfhhmbhghlhiedafojfal/", + "chrome-extension://jbkfoedcorcdjbeocggbefkkoptjpcif/" + ] + }) + }; + + let manifest_content = serde_json::to_string_pretty(&manifest_json)?; + fs::write(&manifest_file, manifest_content) + .context("failed to write manifest file")?; + + println!( + "Successfully installed integration for {name}.", + name = bc.name + ); + } + + Ok(()) +} + +pub fn uninstall(browser: Option) -> Result<()> { + let target_browsers = get_target_browsers(browser)?; + + for bc in target_browsers { + let manifest_file = bc.manifest_dir.join("com.8bit.bitwarden.json"); + let wrapper_file = bc.manifest_dir.join("rbw-browser-bridge"); + + if manifest_file.exists() { + let existing_manifest = fs::read_to_string(&manifest_file) + .context("failed to read existing manifest")?; + if existing_manifest.contains("rbw-browser-bridge") { + println!("Uninstalling integration for {}...", bc.name); + fs::remove_file(&manifest_file) + .context("failed to remove manifest file")?; + if wrapper_file.exists() { + fs::remove_file(&wrapper_file) + .context("failed to remove wrapper script")?; + } + println!( + "Successfully uninstalled integration for {}.", + bc.name + ); + } else { + println!( + "Manifest for {} does not belong to rbw, skipping.", + bc.name + ); + } + } else { + println!("Integration for {} not installed, skipping.", bc.name); + } + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_decrypt_validates_iv_length() { + let key = Aes256CbcHmacKey::new(); + + // Invalid IV length (8 bytes instead of 16) — previously panicked + let mut mac = + Hmac::::new_from_slice(&key.mac_key).expect("valid key"); + mac.update(&[0u8; 8]); + mac.update(&[0u8; 16]); + let tag = mac.finalize().into_bytes(); + + let err = key.decrypt(&[0u8; 8], &tag, &[0u8; 16]).unwrap_err(); + assert!(err.to_string().contains("invalid key or IV length")); + } + + #[test] + fn test_read_message_limits_and_eof() { + // Clean EOF returns Ok(None) + let mut empty = std::io::Cursor::new(Vec::::new()); + assert!(read_message(&mut empty).unwrap().is_none()); + + // Truncated header returns Err + let mut short_hdr = std::io::Cursor::new(vec![1u8, 2]); + assert!(read_message(&mut short_hdr).is_err()); + + // Oversized frame (> MAX_MESSAGE_SIZE) returns Err without allocating + let oversized_len = (MAX_MESSAGE_SIZE + 1).to_ne_bytes(); + let mut oversized = std::io::Cursor::new(oversized_len.to_vec()); + let err = read_message(&mut oversized).unwrap_err(); + assert!(err.to_string().contains("exceeds maximum allowed size")); + + // Valid frame round-trips + let payload = br#"{"command":"ping"}"#; + let mut valid = Vec::new(); + valid.extend_from_slice( + &u32::try_from(payload.len()).unwrap().to_ne_bytes(), + ); + valid.extend_from_slice(payload); + let mut cursor = std::io::Cursor::new(valid); + assert_eq!(read_message(&mut cursor).unwrap().unwrap(), payload); + } + + #[test] + fn test_extract_jwt_sub() { + // Header: {"alg":"none"}, Payload: {"sub":"user-1234"} + let token = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTEyMzQifQ.sig"; + assert_eq!(extract_jwt_sub(token).as_deref(), Some("user-1234")); + } + + #[test] + fn test_normalize_lexical() { + let p = Path::new("/home/user/./bin/../.nix-profile/bin/rbw"); + assert_eq!( + normalize_lexical(p), + PathBuf::from("/home/user/.nix-profile/bin/rbw") + ); + } +} diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bddf0efe..55f347db 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1,6 +1,8 @@ use std::{fmt::Write as _, io::Write as _, os::unix::ffi::OsStrExt as _}; use anyhow::Context as _; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; // The default number of seconds the generated TOTP // code lasts for before a new one must be generated @@ -335,19 +337,82 @@ impl From for DecryptedListCipher { } } -#[derive(Debug, Clone, serde::Serialize)] +/// Custom field type +#[derive(JsonSchema, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum SchemaFieldType { + /// Plain text field + Text, + /// Hidden/secret field (masked in UI) + Hidden, + /// Boolean (true/false) field + Boolean, + /// Linked field + Linked, +} + +/// URI match rule +#[derive(JsonSchema, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum SchemaUriMatchType { + /// Match the domain (and subdomains) of the URI + Domain, + /// Match the exact host (including port) + Host, + /// Match if the URI starts with this string + StartsWith, + /// Match the exact URI + Exact, + /// Match using a regular expression + RegularExpression, + /// Never match this URI + Never, +} + +/// A decrypted Bitwarden cipher entry +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedCipher { + /// The unique identifier (UUID) of this entry. Leave empty when creating a new entry. + #[serde(default)] + #[schemars(title = "ID")] id: String, + /// The unique identifier (UUID) of the organization this entry belongs to. Leave null for personal vault. + #[serde(default)] + #[schemars(title = "Organization ID")] + organization_id: Option, + /// The name of the folder to put this entry in. If the folder doesn't exist, it will be created. + #[schemars(title = "Folder")] folder: Option, + /// The name of this entry (e.g. the name of the website or service). + #[schemars(title = "Name")] name: String, + /// The type-specific data of this entry (`Login`, `Card`, `Identity`, `SecureNote`, or `SshKey`). + #[schemars(title = "Entry Data")] data: DecryptedData, + /// Custom fields associated with this entry. + #[serde(default)] + #[schemars(title = "Custom Fields")] fields: Vec, + /// Notes or description for this entry. + #[schemars(title = "Notes")] notes: Option, + /// Password history for this entry (only updated if the password changes). + #[serde(default)] + #[schemars(title = "Password History")] history: Vec, } impl DecryptedCipher { + fn display_custom_field(&self, field: &str, clipboard: bool) { + for f in &self.fields { + if f.name.to_lowercase().as_str().contains(field) { + val_display_or_store(clipboard, &f.value); + break; + } + } + } fn display_short(&self, desc: &str, clipboard: bool) -> bool { match &self.data { DecryptedData::Login { password, .. } => { @@ -451,17 +516,7 @@ impl DecryptedCipher { 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; - } - } - } + self.display_custom_field(field, clipboard); } }, DecryptedData::Card { @@ -514,17 +569,7 @@ impl DecryptedCipher { } } _ => { - 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; - } - } - } + self.display_custom_field(field, clipboard); } }, DecryptedData::Identity { @@ -617,17 +662,7 @@ impl DecryptedCipher { } } _ => { - 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; - } - } - } + self.display_custom_field(field, clipboard); } }, DecryptedData::SecureNote => match field.parse() { @@ -635,17 +670,7 @@ impl DecryptedCipher { 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; - } - } - } + self.display_custom_field(field, clipboard); } }, DecryptedData::SshKey { @@ -672,17 +697,7 @@ impl DecryptedCipher { } } _ => { - 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; - } - } - } + self.display_custom_field(field, clipboard); } }, } @@ -718,8 +733,8 @@ impl DecryptedCipher { for field in &self.fields { displayed |= display_field( - field.name.as_deref().unwrap_or("(null)"), - Some(field.value.as_deref().unwrap_or("")), + &field.name, + Some(&field.value), clipboard, ); } @@ -837,8 +852,8 @@ impl DecryptedCipher { for field in &self.fields { displayed |= display_field( - field.name.as_deref().unwrap_or("(null)"), - Some(field.value.as_deref().unwrap_or("")), + &field.name, + Some(&field.value), clipboard, ); } @@ -988,9 +1003,7 @@ impl DecryptedCipher { println!("{}", Field::Notes); } for f in &self.fields { - if let Some(name) = &f.name { - println!("{name}"); - } + println!("{}", f.name); } } @@ -1001,6 +1014,14 @@ impl DecryptedCipher { Ok(()) } + + fn display_yaml(&self, desc: &str) -> anyhow::Result<()> { + serde_yaml::to_writer(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 { @@ -1018,96 +1039,400 @@ fn val_display_or_store(clipboard: bool, password: &str) -> bool { } } -#[derive(Debug, Clone, serde::Serialize)] -#[serde(untagged)] +/// Type-specific data of the entry +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(untagged, deny_unknown_fields, remote = "Self")] #[cfg_attr(test, derive(Eq, PartialEq))] enum DecryptedData { + /// Login credentials (username, password, TOTP, URIs) + #[schemars(title = "Login")] Login { + /// The login username + #[schemars(title = "Username")] username: Option, + /// The login password + #[schemars(title = "Password")] password: Option, + /// The TOTP (Time-based One-Time Password) secret key + #[schemars(title = "TOTP Secret")] totp: Option, + /// URIs (URLs) associated with this login + #[schemars(title = "URIs")] uris: Option>, }, + /// Credit/debit card details + #[schemars(title = "Card")] Card { + /// The name on the card + #[schemars(title = "Cardholder Name")] cardholder_name: Option, + /// The card number + #[schemars(title = "Card Number")] number: Option, + /// The card brand (e.g. Visa, Mastercard) + #[schemars(title = "Brand")] brand: Option, + /// The expiration month (MM) + #[schemars(title = "Expiration Month")] exp_month: Option, + /// The expiration year (YYYY) + #[schemars(title = "Expiration Year")] exp_year: Option, + /// The security code (CVV/CVC) + #[schemars(title = "Security Code (CVV)")] code: Option, }, + /// Identity/personal details + #[schemars(title = "Identity")] Identity { + /// Honorific title (e.g. Mr., Ms.) + #[schemars(title = "Title")] title: Option, + /// First name + #[schemars(title = "First Name")] first_name: Option, + /// Middle name + #[schemars(title = "Middle Name")] middle_name: Option, + /// Last name + #[schemars(title = "Last Name")] last_name: Option, + /// Address line 1 + #[schemars(title = "Address Line 1")] address1: Option, + /// Address line 2 + #[schemars(title = "Address Line 2")] address2: Option, + /// Address line 3 + #[schemars(title = "Address Line 3")] address3: Option, + /// City + #[schemars(title = "City")] city: Option, + /// State/Province + #[schemars(title = "State/Province")] state: Option, + /// Postal/ZIP code + #[schemars(title = "Postal/ZIP Code")] postal_code: Option, + /// Country + #[schemars(title = "Country")] country: Option, + /// Phone number + #[schemars(title = "Phone")] phone: Option, + /// Email address + #[schemars(title = "Email")] email: Option, + /// Social Security Number (SSN) or equivalent + #[schemars(title = "SSN")] ssn: Option, + /// Driver's license number + #[schemars(title = "Driver's License")] license_number: Option, + /// Passport number + #[schemars(title = "Passport Number")] passport_number: Option, + /// Preferred username + #[schemars(title = "Username")] username: Option, }, + /// A secure text note + #[schemars(title = "Secure Note")] SecureNote, + /// SSH Key credentials + #[schemars(title = "SSH Key")] SshKey { + /// The SSH public key + #[schemars(title = "Public Key")] public_key: Option, + /// The SSH key fingerprint + #[schemars(title = "Fingerprint")] fingerprint: Option, + /// The SSH private key + #[schemars(title = "Private Key")] private_key: Option, }, } -#[derive(Debug, Clone, serde::Serialize)] +impl Serialize for DecryptedData { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + Self::serialize(self, serializer) + } +} + +impl<'de> Deserialize<'de> for DecryptedData { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let opt = + Option::>::deserialize( + deserializer, + )?; + let Some(mut map) = opt else { + return Ok(Self::SecureNote); + }; + if map.is_empty() { + return Ok(Self::SecureNote); + } + if let Some(type_val) = map.remove("type") { + let type_str = type_val.as_str().ok_or_else(|| { + serde::de::Error::custom("expected string for `type`") + })?; + match type_str { + "secure_note" | "note" => { + if let Some(key) = map.keys().next() { + return Err(serde::de::Error::unknown_field(key, &[])); + } + return Ok(Self::SecureNote); + } + "login" => { + map.entry("password").or_insert(serde_json::Value::Null); + } + "card" => { + map.entry("cardholder_name") + .or_insert(serde_json::Value::Null); + } + "identity" => { + map.entry("title").or_insert(serde_json::Value::Null); + } + "ssh_key" => { + map.entry("public_key").or_insert(serde_json::Value::Null); + } + other => { + return Err(serde::de::Error::unknown_variant( + other, + &[ + "login", + "card", + "identity", + "secure_note", + "note", + "ssh_key", + ], + )); + } + } + } + Self::deserialize(serde_json::Value::Object(map)) + .map_err(serde::de::Error::custom) + } +} + +/// A custom field associated with the entry +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedField { - name: Option, - value: Option, - #[serde(serialize_with = "serialize_field_type", rename = "type")] - ty: Option, + /// The name of the custom field + #[schemars(title = "Field Name")] + name: String, + /// The value of the custom field + #[schemars(title = "Field Value")] + value: String, + /// The type of the custom field + #[serde( + serialize_with = "serialize_field_type", + deserialize_with = "deserialize_field_type", + rename = "type" + )] + #[schemars(with = "SchemaFieldType", title = "Field Type")] + ty: rbw::api::FieldType, + /// The linked field ID (for linked fields) + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option", title = "Linked Field ID")] + linked_id: Option, } #[allow(clippy::trivially_copy_pass_by_ref, clippy::ref_option)] fn serialize_field_type( - ty: &Option, + ty: &rbw::api::FieldType, 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(), + 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_str(s) +} + +fn deserialize_field_type<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + match s.as_str() { + "text" => Ok(rbw::api::FieldType::Text), + "hidden" => Ok(rbw::api::FieldType::Hidden), + "boolean" => Ok(rbw::api::FieldType::Boolean), + "linked" => Ok(rbw::api::FieldType::Linked), + other => Err(serde::de::Error::unknown_variant( + other, + &["text", "hidden", "boolean", "linked"], + )), } } -#[derive(Debug, Clone, serde::Serialize)] -#[cfg_attr(test, derive(Eq, PartialEq))] +/// A historic password entry +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] struct DecryptedHistoryEntry { + /// The date when this password was last used (RFC3339 format) + #[schemars(title = "Last Used Date")] last_used_date: String, + /// The historic password + #[schemars(title = "Password")] password: String, } -#[derive(Debug, Clone, serde::Serialize)] +/// A URI associated with a login entry +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedUri { + /// The URI (URL) + #[schemars(title = "URI")] uri: String, + /// The match type rule for this URI + #[serde( + serialize_with = "serialize_uri_match_type", + deserialize_with = "deserialize_uri_match_type" + )] + #[schemars(with = "Option", title = "Match Type")] match_type: Option, } +// We need custom serialization/deserialization for UriMatchType in user-facing DecryptedUri +// because by default, UriMatchType derives Serialize_repr/Deserialize_repr (integers) +// for Bitwarden API compatibility and local database cache (db.json) backwards compatibility. +// Using custom serialize/deserialize allows us to present friendly strings (like "domain", "host") +// to the user while keeping the internal integer representations intact. +#[allow(clippy::trivially_copy_pass_by_ref, clippy::ref_option)] +fn serialize_uri_match_type( + match_type: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + match match_type { + Some(mt) => { + let s = match mt { + rbw::api::UriMatchType::Domain => "domain", + rbw::api::UriMatchType::Host => "host", + rbw::api::UriMatchType::StartsWith => "starts_with", + rbw::api::UriMatchType::Exact => "exact", + rbw::api::UriMatchType::RegularExpression => { + "regular_expression" + } + rbw::api::UriMatchType::Never => "never", + }; + serializer.serialize_some(s) + } + None => serializer.serialize_none(), + } +} + +fn deserialize_uri_match_type<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = Option; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter, + ) -> std::fmt::Result { + formatter.write_str("uri match type (string or integer)") + } + + fn visit_str(self, s: &str) -> Result + where + E: serde::de::Error, + { + match s { + "domain" => Ok(Some(rbw::api::UriMatchType::Domain)), + "host" => Ok(Some(rbw::api::UriMatchType::Host)), + "starts_with" => Ok(Some(rbw::api::UriMatchType::StartsWith)), + "exact" => Ok(Some(rbw::api::UriMatchType::Exact)), + "regular_expression" => { + Ok(Some(rbw::api::UriMatchType::RegularExpression)) + } + "never" => Ok(Some(rbw::api::UriMatchType::Never)), + _ => Err(serde::de::Error::unknown_variant( + s, + &[ + "domain", + "host", + "starts_with", + "exact", + "regular_expression", + "never", + ], + )), + } + } + + fn visit_u64(self, v: u64) -> Result + where + E: serde::de::Error, + { + match v { + 0 => Ok(Some(rbw::api::UriMatchType::Domain)), + 1 => Ok(Some(rbw::api::UriMatchType::Host)), + 2 => Ok(Some(rbw::api::UriMatchType::StartsWith)), + 3 => Ok(Some(rbw::api::UriMatchType::Exact)), + 4 => Ok(Some(rbw::api::UriMatchType::RegularExpression)), + 5 => Ok(Some(rbw::api::UriMatchType::Never)), + _ => Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Unsigned(v), + &"integer between 0 and 5", + )), + } + } + + fn visit_none(self) -> Result + where + E: serde::de::Error, + { + Ok(None) + } + + fn visit_unit(self) -> Result + where + E: serde::de::Error, + { + Ok(None) + } + + fn visit_some( + self, + deserializer: D, + ) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(self) + } + } + deserializer.deserialize_any(Visitor) +} + fn matches_url( url: &str, match_type: Option, @@ -1391,6 +1716,7 @@ pub fn get( field: Option<&str>, full: bool, raw: bool, + yaml: bool, clipboard: bool, ignore_case: bool, list_fields: bool, @@ -1412,6 +1738,8 @@ pub fn get( decrypted.display_fields_list(); } else if raw { decrypted.display_json(&desc)?; + } else if yaml { + decrypted.display_yaml(&desc)?; } else if full { decrypted.display_long(&desc, clipboard); } else if let Some(field) = field { @@ -1507,8 +1835,7 @@ pub fn search( .filter(|entry| { entry .as_ref() - .map(|entry| entry.search_match(term, folder)) - .unwrap_or(true) + .map_or(true, |entry| entry.search_match(term, folder)) }) .map(|entry| entry.map(std::convert::Into::into)) .collect::>()?; @@ -1555,102 +1882,451 @@ pub fn code( Ok(()) } +fn get_or_create_folder( + access_token: &mut String, + refresh_token: &str, + db: &mut rbw::db::Db, + folder_name: &str, +) -> anyhow::Result> { + 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 { + return Ok(Some(id)); + } + } + + let (new_access_token, id) = rbw::actions::create_folder( + access_token, + refresh_token, + &crate::actions::encrypt(folder_name, None, 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)?; + } + Ok(Some(id)) +} + +#[derive(Debug)] +struct ParsedSshKey { + default_name: Option, + private_key: zeroize::Zeroizing, + public_key: String, + fingerprint: String, +} + +fn parse_ssh_private_key_str( + raw: &str, + path_hint: Option<&std::path::Path>, + mut get_passphrase: F, +) -> anyhow::Result +where + F: FnMut(Option<&str>) -> anyhow::Result>>, +{ + let mut priv_key = match ssh_agent_lib::ssh_key::PrivateKey::from_openssh( + raw, + ) { + Ok(k) => k, + Err(e) => { + if ssh_agent_lib::ssh_key::PublicKey::from_openssh(raw).is_ok() { + anyhow::bail!( + "expected an OpenSSH private key, but found a public key (pass the private key file, not the .pub file)" + ); + } + return Err( + anyhow::Error::new(e).context("failed to parse OpenSSH private key") + ); + } + }; + + if priv_key.is_encrypted() { + for i in 1_u8..=3 { + let err = if i > 1 { + Some(format!("Incorrect passphrase (attempt {i}/3)")) + } else { + None + }; + let passphrase = get_passphrase(err.as_deref())?; + match priv_key.decrypt(passphrase.as_slice()) { + Ok(decrypted) => { + priv_key = decrypted; + break; + } + Err(e) => { + if i == 3 { + return Err(anyhow::Error::new(e) + .context("failed to decrypt SSH private key")); + } + } + } + } + } + + if !matches!( + priv_key.algorithm(), + ssh_agent_lib::ssh_key::Algorithm::Ed25519 + | ssh_agent_lib::ssh_key::Algorithm::Rsa { .. } + ) { + log::warn!( + "SSH key algorithm '{}' is not currently supported by rbw-agent's SSH agent for signing, but will still be stored in Bitwarden.", + priv_key.algorithm() + ); + } + + let comment = priv_key.comment().trim(); + let default_name = if comment.is_empty() { + path_hint + .filter(|p| *p != std::path::Path::new("-")) + .and_then(std::path::Path::file_name) + .and_then(std::ffi::OsStr::to_str) + .filter(|s| !s.is_empty()) + .map(String::from) + } else { + Some(comment.to_string()) + }; + + let public_key = priv_key + .public_key() + .to_openssh() + .context("failed to format SSH public key")?; + let fingerprint = priv_key + .fingerprint(ssh_agent_lib::ssh_key::HashAlg::Sha256) + .to_string(); + let openssh_priv = priv_key + .to_openssh(ssh_agent_lib::ssh_key::LineEnding::LF) + .context("failed to format unencrypted OpenSSH private key")?; + + Ok(ParsedSshKey { + default_name, + private_key: zeroize::Zeroizing::new((*openssh_priv).clone()), + public_key, + fingerprint, + }) +} + +fn load_ssh_key(path: &std::path::Path) -> anyhow::Result { + let raw = if path == std::path::Path::new("-") { + zeroize::Zeroizing::new( + std::io::read_to_string(std::io::stdin()) + .context("failed to read SSH key from stdin")?, + ) + } else { + zeroize::Zeroizing::new(std::fs::read_to_string(path).with_context( + || format!("failed to read SSH key file '{}'", path.display()), + )?) + }; + + parse_ssh_private_key_str(&raw, Some(path), |err| { + let pinentry = rbw::config::Config::load() + .map_or_else(|_| rbw::config::default_pinentry(), |c| c.pinentry); + let environment = crate::actions::get_environment(); + let desc = format!( + "Enter passphrase to decrypt SSH key '{}'", + path.display() + ); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to initialize async runtime for pinentry")?; + let pw = rt + .block_on(rbw::pinentry::getpin( + &pinentry, + "SSH Key Passphrase", + &desc, + err, + &environment, + true, + )) + .context("failed to read SSH key passphrase from pinentry")?; + Ok(zeroize::Zeroizing::new(pw.password().to_vec())) + }) +} + +#[allow(clippy::too_many_lines)] pub fn add( - name: &str, + name: Option<&str>, username: Option<&str>, uris: &[(String, Option)], folder: Option<&str>, + ssh_key: Option<&std::path::Path>, + raw: bool, + yaml: bool, ) -> anyhow::Result<()> { + use is_terminal::IsTerminal as _; + + if !raw && !yaml && ssh_key.is_none() && name.is_none() { + anyhow::bail!( + "Name is required unless using --raw, --yaml, or --ssh-key" + ); + } + + if (raw || yaml) + && ssh_key == Some(std::path::Path::new("-")) + && !std::io::stdin().is_terminal() + { + anyhow::bail!( + "Cannot read both --ssh-key and --raw/--yaml input from stdin" + ); + } + + let parsed_ssh = ssh_key.map(load_ssh_key).transpose()?; + let resolved_name = name.map(String::from).or_else(|| { + parsed_ssh + .as_ref() + .and_then(|s| s.default_name.clone()) + }); + + if !raw && !yaml && resolved_name.is_none() { + anyhow::bail!( + "Name is required when SSH key has no comment and is read from stdin" + ); + } + 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 refresh_token = db.refresh_token.as_ref().unwrap().clone(); - let name = crate::actions::encrypt(name, None)?; + if raw || yaml { + let data = parsed_ssh.as_ref().map_or_else( + || DecryptedData::Login { + username: username.map(String::from), + password: None, + totp: None, + uris: if uris.is_empty() { + None + } else { + Some( + uris.iter() + .map(|(u, mt)| DecryptedUri { + uri: u.clone(), + match_type: *mt, + }) + .collect(), + ) + }, + }, + |ssh| DecryptedData::SshKey { + public_key: Some(ssh.public_key.clone()), + fingerprint: Some(ssh.fingerprint.clone()), + private_key: Some((*ssh.private_key).clone()), + }, + ); - let username = username - .map(|username| crate::actions::encrypt(username, None)) - .transpose()?; + let template = DecryptedCipher { + id: String::new(), + organization_id: None, + folder: folder.map(String::from), + name: resolved_name.as_deref().unwrap_or("").to_string(), + data, + fields: Vec::new(), + notes: None, + history: Vec::new(), + }; - let contents = rbw::edit::edit("", HELP_PW)?; + let edited = if yaml { + let schema = schemars::schema_for!(DecryptedCipher); + let schema_json = serde_json::to_string_pretty(&schema)?; + let mut contents = String::new(); + contents + .push_str("# yaml-language-server: $schema=./schema.json\n"); + contents.push_str(&serde_yaml::to_string(&template)?); + rbw::edit::edit( + &contents, + "", + "rbw.yaml", + &[("schema.json", &schema_json)], + )? + } else { + let contents = serde_json::to_string_pretty(&template)?; + rbw::edit::edit(&contents, "", "rbw.json", &[])? + }; - let (password, notes) = parse_editor(&contents); - let password = password - .map(|password| crate::actions::encrypt(&password, None)) - .transpose()?; - let notes = notes - .map(|notes| crate::actions::encrypt(¬es, None)) - .transpose()?; - 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 decrypted: DecryptedCipher = if yaml { + serde_yaml::from_str(&edited)? + } else { + serde_json::from_str(&edited)? + }; - 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); + if !std::io::stdin().is_terminal() { + if let Some(name) = resolved_name.as_deref() { + decrypted.name = name.to_string(); + } + if let Some(folder) = folder { + decrypted.folder = Some(folder.to_string()); + } + if let DecryptedData::Login { + username: dec_username, + uris: dec_uris, + .. + } = &mut decrypted.data + { + if let Some(username) = username { + *dec_username = Some(username.to_string()); + } + if !uris.is_empty() { + *dec_uris = Some( + uris.iter() + .map(|(u, mt)| DecryptedUri { + uri: u.clone(), + match_type: *mt, + }) + .collect(), + ); + } + } + } + + let (enc_name, enc_data, enc_fields, enc_notes, enc_history) = + encrypt_cipher( + &decrypted, + None, + decrypted.organization_id.as_deref(), + )?; + + let folder_id = if let Some(folder_name) = &decrypted.folder { + get_or_create_folder( + &mut access_token, + &refresh_token, + &mut db, + folder_name, + )? + } else { + None + }; + + if let (Some(access_token), ()) = rbw::actions::add( + &access_token, + &refresh_token, + decrypted.organization_id.as_deref(), + &enc_name, + &enc_data, + &enc_fields, + enc_notes.as_deref(), + folder_id.as_deref(), + &enc_history, + )? { + db.access_token = Some(access_token); save_db(&db)?; } + } else if let Some(ssh) = parsed_ssh { + let name = resolved_name.unwrap(); + let enc_name = crate::actions::encrypt(&name, None, None)?; + let enc_private_key = + crate::actions::encrypt(&ssh.private_key, None, None)?; + let enc_public_key = + crate::actions::encrypt(&ssh.public_key, None, None)?; + let enc_fingerprint = + crate::actions::encrypt(&ssh.fingerprint, None, None)?; + + let folder_id = if let Some(folder_name) = folder { + get_or_create_folder( + &mut access_token, + &refresh_token, + &mut db, + folder_name, + )? + } else { + None + }; - let folders: Vec<(String, String)> = folders + if let (Some(access_token), ()) = rbw::actions::add( + &access_token, + &refresh_token, + None, + &enc_name, + &rbw::db::EntryData::SshKey { + private_key: Some(enc_private_key), + public_key: Some(enc_public_key), + fingerprint: Some(enc_fingerprint), + }, + &[], + None, + folder_id.as_deref(), + &[], + )? { + db.access_token = Some(access_token); + save_db(&db)?; + } + } else { + let name = name.unwrap(); + let name = crate::actions::encrypt(name, None, None)?; + + let username = username + .map(|username| crate::actions::encrypt(username, None, None)) + .transpose()?; + + let contents = rbw::edit::edit("", HELP_PW, "rbw", &[])?; + + let (password, notes) = parse_editor(&contents); + let password = password + .map(|password| crate::actions::encrypt(&password, None, None)) + .transpose()?; + let notes = notes + .map(|notes| crate::actions::encrypt(¬es, None, None)) + .transpose()?; + let uris: Vec<_> = uris .iter() - .cloned() - .map(|(id, name)| { - Ok((id, crate::actions::decrypt(&name, None, None)?)) + .map(|uri| { + Ok(rbw::db::Uri { + uri: crate::actions::encrypt(&uri.0, None, None)?, + match_type: uri.1, + }) }) .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 = if let Some(folder_name) = folder { + get_or_create_folder( + &mut access_token, + &refresh_token, + &mut db, + folder_name, + )? + } else { + None + }; - if let (Some(access_token), ()) = rbw::actions::add( - &access_token, - refresh_token, - &name, - &rbw::db::EntryData::Login { - username, - password, - uris, - totp: None, - }, - notes.as_deref(), - folder_id.as_deref(), - )? { - db.access_token = Some(access_token); - save_db(&db)?; + if let (Some(access_token), ()) = rbw::actions::add( + &access_token, + &refresh_token, + None, + &name, + &rbw::db::EntryData::Login { + username, + password, + uris, + totp: None, + }, + &[], + notes.as_deref(), + folder_id.as_deref(), + &[], + )? { + db.access_token = Some(access_token); + save_db(&db)?; + } } crate::actions::sync()?; - Ok(()) } @@ -1672,73 +2348,49 @@ pub fn generate( // 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 refresh_token = db.refresh_token.as_ref().unwrap().clone(); - let name = crate::actions::encrypt(name, None)?; + let name = crate::actions::encrypt(name, None, None)?; let username = username - .map(|username| crate::actions::encrypt(username, None)) + .map(|username| crate::actions::encrypt(username, None, None)) .transpose()?; - let password = crate::actions::encrypt(&password, None)?; + let enc_password = crate::actions::encrypt(&password, None, None)?; let uris: Vec<_> = uris .iter() .map(|uri| { Ok(rbw::db::Uri { - uri: crate::actions::encrypt(&uri.0, None)?, + uri: crate::actions::encrypt(&uri.0, None, 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); - } - } + let folder_id = if let Some(folder_name) = folder { + get_or_create_folder( + &mut access_token, + &refresh_token, + &mut db, + folder_name, + )? + } else { + None + }; if let (Some(access_token), ()) = rbw::actions::add( &access_token, - refresh_token, + &refresh_token, + None, &name, &rbw::db::EntryData::Login { username, - password: Some(password), + password: Some(enc_password), uris, totp: None, }, + &[], None, folder_id.as_deref(), + &[], )? { db.access_token = Some(access_token); save_db(&db)?; @@ -1750,17 +2402,20 @@ pub fn generate( Ok(()) } +#[allow(clippy::too_many_lines)] pub fn edit( name: Needle, username: Option<&str>, folder: Option<&str>, ignore_case: bool, + raw: bool, + yaml: bool, ) -> 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 access_token = db.access_token.as_ref().unwrap().clone(); + let refresh_token = db.refresh_token.as_ref().unwrap().clone(); let desc = format!( "{}{}", @@ -1768,107 +2423,250 @@ pub fn edit( name ); - let (entry, decrypted) = + let (entry, decrypted_original) = find_entry(&db, name, username, folder, ignore_case) .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(); + if raw || yaml { + let edited = if yaml { + let schema = schemars::schema_for!(DecryptedCipher); + let schema_json = serde_json::to_string_pretty(&schema)?; + let mut contents = String::new(); + contents + .push_str("# yaml-language-server: $schema=./schema.json\n"); + contents.push_str(&serde_yaml::to_string(&decrypted_original)?); + rbw::edit::edit( + &contents, + "", + "rbw.yaml", + &[("schema.json", &schema_json)], + )? + } else { + let contents = serde_json::to_string_pretty(&decrypted_original)?; + rbw::edit::edit(&contents, "", "rbw.json", &[])? + }; + + let mut decrypted_new: DecryptedCipher = if yaml { + serde_yaml::from_str(&edited)? + } else { + serde_json::from_str(&edited)? + }; + + if decrypted_new.id != decrypted_original.id { + log::warn!("Changing the ID in raw content is not supported and will be ignored."); + } + if decrypted_new.organization_id != decrypted_original.organization_id { + log::warn!("Changing the organization_id in raw content is not supported and will be ignored."); + } + + if matches!(decrypted_original.data, DecryptedData::Identity { .. }) { + if let DecryptedData::Login { + username, + password: None, + totp: None, + uris: None, + } = &decrypted_new.data + { + decrypted_new.data = DecryptedData::Identity { + title: None, + first_name: None, + middle_name: None, + last_name: None, + address1: None, + address2: None, + address3: None, + city: None, + state: None, + postal_code: None, + country: None, + phone: None, + email: None, + ssn: None, + license_number: None, + passport_number: None, + username: username.clone(), + }; + } + } + + if let ( + DecryptedData::Login { + password: Some(prev_pw), + .. + }, + DecryptedData::Login { + password: new_password, + .. + }, + ) = (&decrypted_original.data, &decrypted_new.data) + { + if new_password.as_ref() != Some(prev_pw) + && decrypted_new.history == decrypted_original.history + { + decrypted_new.history.insert( + 0, + DecryptedHistoryEntry { + last_used_date: format!( + "{}", + humantime::format_rfc3339( + std::time::SystemTime::now() + ) + ), + password: prev_pw.clone(), + }, + ); + } + } + + let (enc_name, enc_data, enc_fields, enc_notes, enc_history) = + encrypt_cipher( + &decrypted_new, + entry.key.as_deref(), + entry.org_id.as_deref(), + )?; + + let mut folder_id = entry.folder_id.clone(); + if decrypted_new.folder != decrypted_original.folder { + if let Some(folder_name) = &decrypted_new.folder { + folder_id = get_or_create_folder( + &mut access_token, + &refresh_token, + &mut db, + folder_name, + )?; + } else { + folder_id = None; } + } - let contents = rbw::edit::edit(&contents, HELP_PW)?; + if let (Some(new_access_token), ()) = rbw::actions::edit( + &access_token, + &refresh_token, + &entry.id, + entry.org_id.as_deref(), + &enc_name, + &enc_data, + &enc_fields, + enc_notes.as_deref(), + folder_id.as_deref(), + &enc_history, + entry.key.as_deref(), + )? { + db.access_token = Some(new_access_token); + save_db(&db)?; + } + } else { + let (data, fields, notes, history) = match &decrypted_original.data { + DecryptedData::Login { password, .. } => { + let mut contents = + format!("{}\n", password.as_deref().unwrap_or("")); + if let Some(notes) = decrypted_original.notes.as_ref() { + write!(contents, "\n{notes}\n").unwrap(); + } - 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!(); - }; + let contents = + rbw::edit::edit(&contents, HELP_PW, "rbw", &[])?; - 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() + let (password, notes) = parse_editor(&contents); + let password = password + .map(|password| { + crate::actions::encrypt( + &password, + entry.key.as_deref(), + entry.org_id.as_deref(), ) - ), - password: prev_password, + }) + .transpose()?; + let notes = notes + .map(|notes| { + crate::actions::encrypt( + ¬es, + entry.key.as_deref(), + 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!(); + }; + + 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 data = rbw::db::EntryData::Login { + username: entry_username.clone(), + password, + uris: entry_uris.clone(), + totp: entry_totp.clone(), }; - history.insert(0, new_history_entry); + (data, entry.fields.clone(), notes, history) } + DecryptedData::SecureNote => { + let data = rbw::db::EntryData::SecureNote {}; - 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 editor_content = + decrypted_original.notes.as_ref().map_or_else( + || "\n".to_string(), + |notes| format!("{notes}\n"), + ); + let contents = + rbw::edit::edit(&editor_content, HELP_NOTES, "rbw", &[])?; - let editor_content = decrypted.notes.map_or_else( - || "\n".to_string(), - |notes| format!("{notes}\n"), - ); - 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")); - // prepend blank line to be parsed as pw by `parse_editor` - let (_, notes) = parse_editor(&format!("\n{contents}\n")); + let notes = notes + .map(|notes| { + crate::actions::encrypt( + ¬es, + entry.key.as_deref(), + entry.org_id.as_deref(), + ) + }) + .transpose()?; - let notes = notes - .map(|notes| { - crate::actions::encrypt(¬es, entry.org_id.as_deref()) - }) - .transpose()?; + (data, entry.fields.clone(), notes, entry.history.clone()) + } + _ => { + return Err(anyhow::anyhow!( + "modifications are only supported for login and note entries" + )); + } + }; - (data, entry.fields, notes, entry.history) - } - _ => { - return Err(anyhow::anyhow!( - "modifications are only supported for login and note entries" - )); + if let (Some(new_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, + entry.key.as_deref(), + )? { + db.access_token = Some(new_access_token); + save_db(&db)?; } - }; - - 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)?; } crate::actions::sync()?; @@ -2297,6 +3095,174 @@ fn decrypt_search_cipher( }) } +#[allow(clippy::ref_option)] +fn encrypt_field( + field: &Option, + entry_key: Option<&str>, + org_id: Option<&str>, +) -> anyhow::Result> { + field + .as_deref() + .map(|v| crate::actions::encrypt(v, entry_key, org_id)) + .transpose() +} + +#[allow(clippy::too_many_lines)] +fn encrypt_cipher( + decrypted: &DecryptedCipher, + entry_key: Option<&str>, + org_id: Option<&str>, +) -> anyhow::Result<( + String, // encrypted name + rbw::db::EntryData, // encrypted data + Vec, // encrypted fields + Option, // encrypted notes + Vec, // encrypted history +)> { + let name = crate::actions::encrypt(&decrypted.name, entry_key, org_id)?; + let notes = decrypted + .notes + .as_deref() + .map(|notes| crate::actions::encrypt(notes, entry_key, org_id)) + .transpose()?; + + let fields = decrypted + .fields + .iter() + .map(|field| { + Ok(rbw::db::Field { + ty: Some(field.ty), + name: Some(crate::actions::encrypt( + &field.name, + entry_key, + org_id, + )?), + value: Some(crate::actions::encrypt( + &field.value, + entry_key, + org_id, + )?), + linked_id: field.linked_id, + }) + }) + .collect::>>()?; + + let history = decrypted + .history + .iter() + .map(|history_entry| { + Ok(rbw::db::HistoryEntry { + last_used_date: history_entry.last_used_date.clone(), + password: crate::actions::encrypt( + &history_entry.password, + entry_key, + org_id, + )?, + }) + }) + .collect::>>()?; + + let data = match &decrypted.data { + DecryptedData::Login { + username, + password, + totp, + uris, + } => { + let username = encrypt_field(username, entry_key, org_id)?; + let password = encrypt_field(password, entry_key, org_id)?; + let totp = encrypt_field(totp, entry_key, org_id)?; + let uris = uris + .as_ref() + .map(|uris| { + uris.iter() + .map(|uri| { + Ok(rbw::db::Uri { + uri: crate::actions::encrypt( + &uri.uri, entry_key, org_id, + )?, + match_type: uri.match_type, + }) + }) + .collect::>>() + }) + .transpose()? + .unwrap_or_default(); + + rbw::db::EntryData::Login { + username, + password, + totp, + uris, + } + } + DecryptedData::Card { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + } => rbw::db::EntryData::Card { + cardholder_name: encrypt_field(cardholder_name, entry_key, org_id)?, + number: encrypt_field(number, entry_key, org_id)?, + brand: encrypt_field(brand, entry_key, org_id)?, + exp_month: encrypt_field(exp_month, entry_key, org_id)?, + exp_year: encrypt_field(exp_year, entry_key, org_id)?, + code: encrypt_field(code, entry_key, org_id)?, + }, + DecryptedData::Identity { + title, + first_name, + middle_name, + last_name, + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + } => rbw::db::EntryData::Identity { + title: encrypt_field(title, entry_key, org_id)?, + first_name: encrypt_field(first_name, entry_key, org_id)?, + middle_name: encrypt_field(middle_name, entry_key, org_id)?, + last_name: encrypt_field(last_name, entry_key, org_id)?, + address1: encrypt_field(address1, entry_key, org_id)?, + address2: encrypt_field(address2, entry_key, org_id)?, + address3: encrypt_field(address3, entry_key, org_id)?, + city: encrypt_field(city, entry_key, org_id)?, + state: encrypt_field(state, entry_key, org_id)?, + postal_code: encrypt_field(postal_code, entry_key, org_id)?, + country: encrypt_field(country, entry_key, org_id)?, + phone: encrypt_field(phone, entry_key, org_id)?, + email: encrypt_field(email, entry_key, org_id)?, + ssn: encrypt_field(ssn, entry_key, org_id)?, + license_number: encrypt_field(license_number, entry_key, org_id)?, + passport_number: encrypt_field(passport_number, entry_key, org_id)?, + username: encrypt_field(username, entry_key, org_id)?, + }, + DecryptedData::SecureNote => rbw::db::EntryData::SecureNote, + DecryptedData::SshKey { + public_key, + fingerprint, + private_key, + } => rbw::db::EntryData::SshKey { + public_key: encrypt_field(public_key, entry_key, org_id)?, + fingerprint: encrypt_field(fingerprint, entry_key, org_id)?, + private_key: encrypt_field(private_key, entry_key, org_id)?, + }, + }; + + Ok((name, data, fields, notes, history)) +} + 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 @@ -2327,7 +3293,8 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { entry.org_id.as_deref(), ) }) - .transpose()?, + .transpose()? + .unwrap_or_default(), value: field .value .as_ref() @@ -2338,8 +3305,10 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { entry.org_id.as_deref(), ) }) - .transpose()?, - ty: field.ty, + .transpose()? + .unwrap_or_default(), + ty: field.ty.unwrap_or(rbw::api::FieldType::Text), + linked_id: field.linked_id, }) }) .collect::>()?; @@ -2613,6 +3582,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { Ok(DecryptedCipher { id: entry.id.clone(), + organization_id: entry.org_id.clone(), folder, name: crate::actions::decrypt( &entry.name, @@ -2815,6 +3785,290 @@ fn display_field(name: &str, field: Option<&str>, clipboard: bool) -> bool { mod test { use super::*; + #[test] + fn test_raw_yaml_serde() { + let original = DecryptedCipher { + id: "some-id".to_string(), + organization_id: Some("some-org".to_string()), + folder: Some("some-folder".to_string()), + name: "some-name".to_string(), + data: DecryptedData::Login { + username: Some("some-user".to_string()), + password: Some("some-password".to_string()), + totp: Some("some-totp".to_string()), + uris: Some(vec![ + DecryptedUri { + uri: "http://example.com".to_string(), + match_type: Some(rbw::api::UriMatchType::Domain), + }, + DecryptedUri { + uri: "http://example.org".to_string(), + match_type: None, + }, + ]), + }, + fields: vec![ + DecryptedField { + name: "field1".to_string(), + value: "value1\nline2\nline3".to_string(), + ty: rbw::api::FieldType::Text, + linked_id: None, + }, + DecryptedField { + name: "field2".to_string(), + value: "value2".to_string(), + ty: rbw::api::FieldType::Hidden, + linked_id: Some(rbw::api::LinkedIdType::LoginUsername), + }, + ], + notes: Some("some-notes\nline2\nline3".to_string()), + history: vec![DecryptedHistoryEntry { + last_used_date: "2026-05-29T14:55:19-07:00".to_string(), + password: "old-password".to_string(), + }], + }; + + // Serialize to JSON + let json = serde_json::to_string_pretty(&original).unwrap(); + // Deserialize from JSON + let deserialized_json: DecryptedCipher = + serde_json::from_str(&json).unwrap(); + assert_eq!(original, deserialized_json); + + // Serialize to YAML + let yaml = serde_yaml::to_string(&original).unwrap(); + // All multi-line strings should be block literals. + // In YAML, block literal starts with `|` + assert!(yaml.contains("notes: |")); + assert!(yaml.contains("value: |")); + assert!(yaml.contains("some-notes")); + assert!(yaml.contains("value1")); + assert!(yaml.contains("line2")); + assert!(yaml.contains("line3")); + + // Deserialize from YAML + let deserialized_yaml: DecryptedCipher = + serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(original, deserialized_yaml); + + // Test Schema Generation + let schema = schemars::schema_for!(DecryptedCipher); + let schema_json = serde_json::to_string_pretty(&schema).unwrap(); + assert!(schema_json.contains("DecryptedCipher")); + assert!(schema_json.contains("organization_id")); + assert!(schema_json.contains("SchemaUriMatchType")); + assert!(schema_json.contains("Field Type")); + } + + #[test] + fn test_decrypted_data_disambiguation() { + // 1. Empty map `data: {}` deserializes as SecureNote + let empty_map_json = r#"{ + "name": "my-note", + "data": {}, + "notes": "secret note" + }"#; + let cipher: DecryptedCipher = + serde_json::from_str(empty_map_json).unwrap(); + assert_eq!(cipher.data, DecryptedData::SecureNote); + + // 2. Explicit `"type": "identity"` with only `username` deserializes as Identity + let identity_json = r#"{ + "name": "my-identity", + "data": { + "type": "identity", + "username": "alice" + } + }"#; + let cipher: DecryptedCipher = + serde_json::from_str(identity_json).unwrap(); + assert!(matches!( + cipher.data, + DecryptedData::Identity { + username: Some(ref u), + .. + } if u == "alice" + )); + + // 3. Explicit `"type": "card"` with no other fields deserializes as Card + let card_json = r#"{ + "name": "my-card", + "data": { + "type": "card" + } + }"#; + let cipher: DecryptedCipher = serde_json::from_str(card_json).unwrap(); + assert!(matches!(cipher.data, DecryptedData::Card { .. })); + } + + #[test] + fn test_parse_ssh_private_key() { + use ssh_agent_lib::ssh_key::{LineEnding, PrivateKey}; + + let mut rng = rand_8::rngs::OsRng; + let keypair = + ssh_agent_lib::ssh_key::private::Ed25519Keypair::random(&mut rng); + let priv_with_comment = PrivateKey::new( + ssh_agent_lib::ssh_key::private::KeypairData::Ed25519( + keypair.clone(), + ), + "alice@example.com", + ) + .unwrap(); + let openssh_str = + priv_with_comment.to_openssh(LineEnding::LF).unwrap(); + + // 1. Key comment takes priority over filename for default_name + let parsed = parse_ssh_private_key_str( + &openssh_str, + Some(std::path::Path::new("/home/alice/.ssh/id_ed25519")), + |_| unreachable!(), + ) + .unwrap(); + assert_eq!(parsed.default_name.as_deref(), Some("alice@example.com")); + assert!(parsed.public_key.starts_with("ssh-ed25519 ")); + assert!(parsed.fingerprint.starts_with("SHA256:")); + + // 2. Empty comment falls back to filename + let priv_no_comment = PrivateKey::new( + ssh_agent_lib::ssh_key::private::KeypairData::Ed25519(keypair), + "", + ) + .unwrap(); + let openssh_no_comment = + priv_no_comment.to_openssh(LineEnding::LF).unwrap(); + let parsed_no_comment = parse_ssh_private_key_str( + &openssh_no_comment, + Some(std::path::Path::new("/home/alice/.ssh/id_ed25519")), + |_| unreachable!(), + ) + .unwrap(); + assert_eq!( + parsed_no_comment.default_name.as_deref(), + Some("id_ed25519") + ); + + // 3. Passphrase-encrypted key decrypts (with retry on wrong passphrase) and outputs unencrypted OpenSSH key + let encrypted_priv = + priv_with_comment.encrypt(&mut rng, b"correct-horse").unwrap(); + let encrypted_openssh = + encrypted_priv.to_openssh(LineEnding::LF).unwrap(); + let mut attempts = 0; + let parsed_enc = parse_ssh_private_key_str( + &encrypted_openssh, + None, + |err| { + attempts += 1; + if attempts == 1 { + assert!(err.is_none()); + Ok(zeroize::Zeroizing::new(b"wrong-pass".to_vec())) + } else { + assert!(err.unwrap().contains("attempt 2/3")); + Ok(zeroize::Zeroizing::new(b"correct-horse".to_vec())) + } + }, + ) + .unwrap(); + assert_eq!(attempts, 2); + let reparsed = + PrivateKey::from_openssh(&*parsed_enc.private_key).unwrap(); + assert!(!reparsed.is_encrypted()); + assert_eq!(parsed_enc.public_key, parsed.public_key); + assert_eq!(parsed_enc.fingerprint, parsed.fingerprint); + + // 4. Passing a public key file produces a clear error + let pub_err = parse_ssh_private_key_str( + &parsed.public_key, + Some(std::path::Path::new("id_ed25519.pub")), + |_| unreachable!(), + ) + .unwrap_err(); + assert!(pub_err + .to_string() + .contains("expected an OpenSSH private key, but found a public key")); + } + + #[test] + fn test_strict_validation() { + // 1. Test unknown field in DecryptedCipher fails + let bad_cipher_json = r#"{ + "id": "some-id", + "name": "some-name", + "data": { + "username": "some-user" + }, + "unknown_field": "invalid" + }"#; + let res: Result = + serde_json::from_str(bad_cipher_json); + assert!(res.is_err()); + assert!(res + .unwrap_err() + .to_string() + .contains("unknown field `unknown_field`")); + + // 2. Test unknown field in DecryptedData (Login variant) fails + let bad_login_json = r#"{ + "id": "some-id", + "name": "some-name", + "data": { + "username": "some-user", + "bad_field": "invalid" + } + }"#; + let res: Result = + serde_json::from_str(bad_login_json); + assert!(res.is_err()); + let err_msg = res.unwrap_err().to_string(); + assert!( + err_msg.contains("data did not match any variant") + || err_msg.contains("unknown field") + ); + + // 3. Test missing required field in DecryptedField fails + let missing_name_field_json = r#"{ + "id": "some-id", + "name": "some-name", + "data": { + "username": "some-user" + }, + "fields": [ + { + "value": "some-value", + "type": "text" + } + ] + }"#; + let res: Result = + serde_json::from_str(missing_name_field_json); + assert!(res.is_err()); + assert!(res + .unwrap_err() + .to_string() + .contains("missing field `name`")); + + let missing_type_field_json = r#"{ + "id": "some-id", + "name": "some-name", + "data": { + "username": "some-user" + }, + "fields": [ + { + "name": "some-name", + "value": "some-value" + } + ] + }"#; + let res: Result = + serde_json::from_str(missing_type_field_json); + assert!(res.is_err()); + assert!(res + .unwrap_err() + .to_string() + .contains("missing field `type`")); + } + #[test] fn test_find_entry() { let entries = &[ diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index ff2ec740..a4317bbc 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -4,6 +4,7 @@ use anyhow::Context as _; use clap::{CommandFactory as _, Parser as _}; mod actions; +mod browser; mod commands; mod sock; @@ -77,8 +78,14 @@ enum Opt { field: Option, #[arg(long, help = "Display the notes in addition to the password")] full: bool, - #[structopt(long, help = "Display output as JSON")] + #[arg( + long, + conflicts_with = "yaml", + help = "Display output as JSON" + )] raw: bool, + #[arg(long, help = "Display output as YAML")] + yaml: bool, #[cfg(feature = "clipboard")] #[structopt(short, long, help = "Copy result to clipboard")] clipboard: bool, @@ -127,8 +134,10 @@ enum Opt { remainder will be saved as a note." )] Add { - #[arg(help = "Name of the password entry")] - name: String, + #[arg( + help = "Name of the entry (optional if using --raw, --yaml, or --ssh-key)" + )] + name: Option, #[arg(help = "Username for the password entry")] user: Option, #[arg( @@ -137,8 +146,22 @@ enum Opt { number_of_values = 1 )] uri: Vec, - #[arg(long, help = "Folder for the password entry")] + #[arg(long, help = "Folder for the entry")] folder: Option, + #[arg( + long = "ssh-key", + conflicts_with_all = ["user", "uri"], + help = "Path to an OpenSSH private key file to add as an SSH key entry (or '-' for stdin)" + )] + ssh_key: Option, + #[arg( + long, + conflicts_with = "yaml", + help = "Add entry from JSON raw input" + )] + raw: bool, + #[arg(long, help = "Add entry from YAML raw input")] + yaml: bool, }, #[command( @@ -209,6 +232,14 @@ enum Opt { Edit { #[command(flatten)] find_args: FindArgs, + #[arg( + long, + conflicts_with = "yaml", + help = "Edit entry as JSON raw" + )] + raw: bool, + #[arg(long, help = "Edit entry as YAML")] + yaml: bool, }, #[command(about = "Remove a given entry", visible_alias = "rm")] @@ -237,6 +268,15 @@ enum Opt { about = "Generate completion script for the given shell" )] GenCompletions { shell: CompletionShell }, + + #[command( + name = "browser-integration", + about = "Manage browser extension integration" + )] + BrowserIntegration { + #[command(subcommand)] + action: BrowserIntegrationAction, + }, } impl Opt { @@ -263,6 +303,9 @@ impl Opt { Self::Purge => "purge".to_string(), Self::StopAgent => "stop-agent".to_string(), Self::GenCompletions { .. } => "gen-completions".to_string(), + Self::BrowserIntegration { action } => { + format!("browser-integration {}", action.subcommand_name()) + } } } } @@ -278,6 +321,43 @@ enum CompletionShell { Fig, } +#[derive(Debug, clap::Subcommand)] +pub enum BrowserIntegrationAction { + #[command(hide = true, about = "Run the native messaging host loop")] + Host, + #[command(about = "Install the native messaging manifests")] + Install { + #[arg(value_enum, help = "Specific browser to install for")] + browser: Option, + #[arg( + long, + help = "Force installation even if a manifest already exists" + )] + force: bool, + #[arg( + long, + help = "Explicit path to the rbw binary to use in the wrapper script" + )] + rbw_path: Option, + }, + #[command(about = "Uninstall the native messaging manifests")] + Uninstall { + #[arg(value_enum, help = "Specific browser to uninstall from")] + browser: Option, + }, +} + +impl BrowserIntegrationAction { + fn subcommand_name(&self) -> String { + match self { + Self::Host => "host", + Self::Install { .. } => "install", + Self::Uninstall { .. } => "uninstall", + } + .to_string() + } +} + #[derive(Debug, clap::Parser)] enum Config { #[command(about = "Show the values of all configuration settings")] @@ -343,6 +423,7 @@ fn main() { field, full, raw, + yaml, #[cfg(feature = "clipboard")] clipboard, list_fields, @@ -353,6 +434,7 @@ fn main() { field.as_deref(), full, raw, + yaml, #[cfg(feature = "clipboard")] clipboard, #[cfg(not(feature = "clipboard"))] @@ -385,8 +467,11 @@ fn main() { user, uri, folder, + ssh_key, + raw, + yaml, } => commands::add( - &name, + name.as_deref(), user.as_deref(), &uri.iter() // XXX not sure what the ui for specifying the match type @@ -394,6 +479,9 @@ fn main() { .map(|uri| (uri.clone(), None)) .collect::>(), folder.as_deref(), + ssh_key.as_deref(), + raw, + yaml, ), Opt::Generate { len, @@ -430,11 +518,17 @@ fn main() { ty, ) } - Opt::Edit { find_args } => commands::edit( + Opt::Edit { + find_args, + raw, + yaml, + } => commands::edit( find_args.needle, find_args.user.as_deref(), find_args.folder.as_deref(), find_args.ignorecase, + raw, + yaml, ), Opt::Remove { find_args } => commands::remove( find_args.needle, @@ -451,6 +545,17 @@ fn main() { Opt::Lock => commands::lock(), Opt::Purge => commands::purge(), Opt::StopAgent => commands::stop_agent(), + Opt::BrowserIntegration { action } => match action { + BrowserIntegrationAction::Host => browser::host(), + BrowserIntegrationAction::Install { + browser, + force, + rbw_path, + } => browser::install(browser, force, rbw_path.as_deref()), + BrowserIntegrationAction::Uninstall { browser } => { + browser::uninstall(browser) + } + }, Opt::GenCompletions { shell } => { match shell { CompletionShell::Bash => { diff --git a/src/dirs.rs b/src/dirs.rs index 079fc880..2fb12dd3 100644 --- a/src/dirs.rs +++ b/src/dirs.rs @@ -97,7 +97,7 @@ fn runtime_dir() -> std::path::PathBuf { format!( "{}/{}-{}", std::env::temp_dir().to_string_lossy(), - &profile(), + profile(), rustix::process::getuid().as_raw() ) .into() diff --git a/src/edit.rs b/src/edit.rs index 7295a93a..5c43ec28 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -4,7 +4,12 @@ use std::io::{Read as _, Write as _}; use is_terminal::IsTerminal as _; -pub fn edit(contents: &str, help: &str) -> Result { +pub fn edit( + contents: &str, + help: &str, + filename: &str, + extra_files: &[(&str, &str)], +) -> Result { if !std::io::stdin().is_terminal() { // directly read from piped content return match std::io::read_to_string(std::io::stdin()) { @@ -20,12 +25,18 @@ pub fn edit(contents: &str, help: &str) -> Result { }); let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("rbw"); + let file = dir.path().join(filename); 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); + for (name, data) in extra_files { + let path = dir.path().join(name); + let mut fh = std::fs::File::create(&path).unwrap(); + fh.write_all(data.as_bytes()).unwrap(); + } + let (cmd, args) = if contains_shell_metacharacters(&editor) { let mut cmdline = std::ffi::OsString::new(); cmdline.extend([ diff --git a/src/protocol.rs b/src/protocol.rs index ec0c06eb..c9c48c92 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -181,11 +181,14 @@ pub enum Action { }, Encrypt { plaintext: String, + #[serde(default)] + entry_key: Option, org_id: Option, }, ClipboardStore { text: String, }, + GetMasterKey, Quit, Version, } @@ -198,4 +201,5 @@ pub enum Response { Decrypt { plaintext: String }, Encrypt { cipherstring: String }, Version { version: u32 }, + MasterKey { key: String }, }