├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── .rustfmt.toml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── bin ├── git-credential-rbw ├── pass-import ├── rbw-fzf ├── rbw-pinentry-keyring └── rbw-rofi ├── deny.toml ├── src ├── actions.rs ├── api.rs ├── base64.rs ├── bin │ ├── rbw-agent │ │ ├── actions.rs │ │ ├── agent.rs │ │ ├── daemon.rs │ │ ├── debugger.rs │ │ ├── main.rs │ │ ├── notifications.rs │ │ ├── sock.rs │ │ └── timeout.rs │ └── rbw │ │ ├── actions.rs │ │ ├── commands.rs │ │ ├── main.rs │ │ └── sock.rs ├── cipherstring.rs ├── config.rs ├── db.rs ├── dirs.rs ├── edit.rs ├── error.rs ├── identity.rs ├── json.rs ├── lib.rs ├── locked.rs ├── pinentry.rs ├── prelude.rs ├── protocol.rs ├── pwgen.rs └── wordlist.rs └── tools └── generate_wordlist /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: {} 6 | env: 7 | RUST_BACKTRACE: 1 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: dtolnay/rust-toolchain@stable 14 | - run: cargo build --all-targets --all-features 15 | build-musl: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - run: sudo apt-get install clang-18 19 | - uses: actions/checkout@v3 20 | - uses: dtolnay/rust-toolchain@stable 21 | with: 22 | targets: x86_64-unknown-linux-musl 23 | - run: TARGET_CC=clang-18 TARGET_AR=llvm-ar-18 cargo build --all-targets --all-features --target x86_64-unknown-linux-musl 24 | build-macos: 25 | runs-on: macos-latest 26 | steps: 27 | - uses: actions/checkout@v3 28 | - uses: dtolnay/rust-toolchain@stable 29 | - run: cargo build --all-targets --all-features 30 | build-android: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v3 34 | - uses: dtolnay/rust-toolchain@stable 35 | - uses: cargo-bins/cargo-binstall@main 36 | - run: cargo binstall cross 37 | - run: cross check --all-targets --target aarch64-linux-android --no-default-features 38 | test: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v3 42 | - uses: dtolnay/rust-toolchain@stable 43 | - run: cargo test --all-features 44 | test-musl: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - run: sudo apt-get install clang-18 48 | - uses: actions/checkout@v3 49 | - uses: dtolnay/rust-toolchain@stable 50 | with: 51 | targets: x86_64-unknown-linux-musl 52 | - run: TARGET_CC=clang-18 TARGET_AR=llvm-ar-18 cargo test --all-features --target x86_64-unknown-linux-musl 53 | test-macos: 54 | runs-on: macos-latest 55 | steps: 56 | - uses: actions/checkout@v3 57 | - uses: dtolnay/rust-toolchain@stable 58 | - run: cargo test --all-features 59 | lint: 60 | runs-on: ubuntu-latest 61 | steps: 62 | - uses: actions/checkout@v3 63 | - uses: dtolnay/rust-toolchain@stable 64 | with: 65 | components: clippy, rustfmt 66 | - uses: cargo-bins/cargo-binstall@main 67 | - run: cargo binstall cargo-deny 68 | - run: cargo clippy --all-targets --all-features -- -Dwarnings 69 | - run: cargo fmt --check 70 | - run: cargo deny check 71 | doc: 72 | runs-on: ubuntu-latest 73 | steps: 74 | - uses: actions/checkout@v3 75 | - uses: dtolnay/rust-toolchain@stable 76 | - run: RUSTDOCFLAGS="-Dwarnings" cargo doc --all-features 77 | - run: cargo test --doc --all-features 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /pkg 2 | /target 3 | /target-ra 4 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | max_width = 78 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.13.2] - 2025-01-06 4 | 5 | ## Fixed 6 | 7 | * Try another clipboard backend to try to fix cross platform issues. (Mag 8 | Mell, #226) 9 | * `rbw unlocked` no longer starts the agent if it isn't running. (#223) 10 | * The cardholder_name field is now correctly populated for card entries. 11 | (#204) 12 | * Fix ip address url matching when using the domain match type. (#211) 13 | * Make the behavior of matching urls with no paths when using the exact match 14 | type more consistent. (#211) 15 | 16 | ## [1.13.1] - 2024-12-27 17 | 18 | ### Fixed 19 | 20 | * Moved clipboard support to a (default-enabled) feature, since not all 21 | platforms support it (disabling this feature should allow Android builds to 22 | work again). 23 | 24 | ## [1.13.0] - 2024-12-26 25 | 26 | ### Fixed 27 | 28 | * Fix preventing the password type selectors in `rbw generate` from being 29 | used together. (antecrescent, #198) 30 | * Fix `--clipboard` on Wayland (Maksim Karelov, #192) 31 | * Fix parsing vaults with entries that have non-null field types (Tin Lai, #212) 32 | * Fix lock timeout being reset when checking version (aeber, #216) 33 | * Update API request headers to pass new stricter validation on the official bitwarden.com server (Davide Laezza, #219) 34 | * Make it possible to start the rbw agent process from a graphical session and then access it over SSH (Wim de With, #221) 35 | 36 | ## [1.12.1] - 2024-07-28 37 | 38 | ### Fixed 39 | 40 | * Fix decrypting folder names of entries with individual item encryption keys. 41 | 42 | ## [1.12.0] - 2024-07-28 43 | 44 | *NOTE: If you were affected by issue #163 (getting messages like `failed to 45 | decrypt encrypted secret: invalid mac` when doing any operations on your 46 | vault), you will need to `rbw sync` after upgrading in order to update your 47 | local vault with the necessary new data.* 48 | 49 | ### Fixed 50 | 51 | * Support decrypting entries encrypted with invididual item encryption keys, 52 | which are now generated by default from the official Bitwarden clients. 53 | (#163) 54 | * Correctly handle lowercased and padded base32 TOTP secrets. (owl, #189) 55 | * Make locking agent memory to RAM optional, since it appears to not always 56 | be available. (#143) 57 | 58 | ## [1.11.1] - 2024-06-26 59 | 60 | ### Fixed 61 | 62 | * Updated the prelogin API endpoint to use the identity API instead of the 63 | base API, to correspond with upcoming changes to the official Bitwarden 64 | server (see https://github.com/bitwarden/server/pull/4206) 65 | 66 | ## [1.11.0] - 2024-06-20 67 | 68 | ### Added 69 | 70 | * Support SSO login. (dezeroku, #174) 71 | * Added `rbw search`, which finds and displays the name of entries matching a 72 | given search term. 73 | * Added `--ignorecase` as an option to several subcommands. (Maximilian 74 | Götsch, #164) 75 | * The JSON output given by `--raw` now also includes the field type. 76 | 77 | ### Fixed 78 | 79 | * Fixed the client id used when logging in, which was causing problems with 80 | the official Bitwarden server. (Merlin Marek, #186) 81 | * Reworked `rbw-pinentry-keyring` to support passwords with spaces and 2fa 82 | codes. (Henk van Maanen, #178) 83 | * Try less hard to parse input as a url (so that using `rbw get` on an entry 84 | name containing a `:` works as expected). 85 | 86 | ## [1.10.2] - 2024-05-20 87 | 88 | ### Fixed 89 | 90 | * Fix logging into the official Bitwarden server due to changes on their end 91 | (Gabriel Górski, #175) 92 | 93 | ## [1.10.1] - 2024-05-08 94 | 95 | ### Added 96 | 97 | * `rbw code` supports TOTP codes which use a SHA256 or SHA512 hash (Jonas, #172) 98 | 99 | ### Fixed 100 | 101 | * Fix `rbw code` searching by UUID (Robert Günzler, #169) 102 | 103 | ## [1.10.0] - 2024-04-20 104 | 105 | ### Added 106 | 107 | * `rbw get` now supports searching by URL as well (proxict, #132) 108 | * `rbw code` now supports `--clipboard`, and has an alias of `rbw totp` (#127) 109 | 110 | ### Changed 111 | 112 | * Set a user agent for all API calls, not just logging in (#165) 113 | 114 | ### Fixed 115 | 116 | * Also create runtime directories when running with `--no-daemonize` (Wim de With, #155) 117 | * Fix builds on NetBSD (#105) 118 | * Fix logging in when the configured email address differs in case from the email address used when registering (#158) 119 | * Fix editing passwords inadvertently clearing custom field values (#142) 120 | 121 | ## [1.9.0] - 2024-01-01 122 | 123 | ### Added 124 | 125 | * Secure notes can now be edited (Tin Lai, #137) 126 | * Piping passwords to `rbw edit` is now possible (Tin Lai, #138) 127 | 128 | ### Fixed 129 | 130 | * More consistent behavior from `rbw get --field`, and fix some panics (Jörg Thalheim, #131) 131 | * Fix handling of pinentry EOF (Jörg Thalheim, #140) 132 | * Pass a user agent header to fix logging into the official bitwarden server (Maksim Karelov, #151) 133 | * Support the official bitwarden.eu server (Edvin Åkerfeldt, #152) 134 | 135 | ## [1.8.3] - 2023-07-20 136 | 137 | ### Fixed 138 | 139 | * Fixed running on linux without an X11 context available. (Benjamin Jacobs, 140 | #126) 141 | 142 | ## [1.8.2] - 2023-07-19 143 | 144 | ### Fixed 145 | 146 | * Fixed several issues with notification-based background syncing, it should 147 | be much more reliable now. 148 | 149 | ## [1.8.1] - 2023-07-18 150 | 151 | ### Fixed 152 | 153 | * `rbw config set notifications_url` now actually works 154 | 155 | ## [1.8.0] - 2023-07-18 156 | 157 | ### Added 158 | 159 | * `rbw get --clipboard` to copy the result to the clipboard instead of 160 | displaying it on stdout. (eatradish, #120) 161 | * Background syncing now additionally happens when the server notifies the 162 | agent of password updates, instead of needing to wait for the 163 | `sync_interval` timer. (Bernd Schoolman, #115) 164 | * New helper script `rbw-pinentry-keyring` which can be used as an alternate 165 | pinentry program (via `rbw config set pinentry rbw-pinentry-keyring`) to 166 | automatically read the master password from the system keyring. Currently 167 | only supports the Gnome keyring via `secret-tool`. (Kai Frische, #122) 168 | * Yubikeys in OTP mode are now supported for logging into a Bitwarden server. 169 | (troyready, #123) 170 | 171 | ### Fixed 172 | 173 | * Better error reporting when `rbw login` or `rbw register` fail. 174 | 175 | ## [1.7.1] - 2023-03-27 176 | 177 | ### Fixed 178 | 179 | * argon2 actually works now (#113, Bernd Schoolmann) 180 | 181 | ## [1.7.0] - 2023-03-25 182 | 183 | ### Added 184 | 185 | * `rbw` now automatically syncs the database from the server at a specified 186 | interval while it is running. This defaults to once an hour, but is 187 | configurable via the `sync_interval` option 188 | * Email 2FA is now supported (#111, René 'Necoro' Neumann) 189 | * argon2 KDF is now supported (#109, Bernd Schoolmann) 190 | 191 | ### Fixed 192 | 193 | * `rbw --version` now works again 194 | 195 | ## [1.6.0] - 2023-03-09 196 | 197 | ### Added 198 | 199 | * `rbw get` now supports a `--raw` option to display the entire contents of 200 | the entry in JSON format (#97, classabbyamp) 201 | 202 | ## [1.5.0] - 2023-02-18 203 | 204 | ### Added 205 | 206 | * Support for authenticating to self-hosted Bitwarden servers using client 207 | certificates (#92, Filipe Pina) 208 | * Support multiple independent profiles via the `RBW_PROFILE` environment 209 | variable (#93, Skia) 210 | * Add `rbw get --field` (#95, Jericho Keyne) 211 | 212 | ### Fixed 213 | 214 | * Don't panic when not all stdout is read (#82, witcher) 215 | * Fixed duplicated alias names in help output (#46) 216 | 217 | ## [1.4.3] - 2022-02-10 218 | 219 | ### Fixed 220 | 221 | * Restored packaged scripts to the crate bundle, since they are used by some 222 | downstream packages (no functional changes) (#81) 223 | 224 | ## [1.4.2] - 2022-02-10 225 | 226 | ### Changed 227 | 228 | * Device id is now stored in a separate file in the local data directory 229 | instead of as part of the config (#74) 230 | 231 | ### Fixed 232 | 233 | * Fix api renaming in official bitwarden server (#80) 234 | 235 | ## [1.4.1] - 2021-10-28 236 | 237 | ### Added 238 | 239 | * `bin/git-credential-rbw` to be used as a 240 | [git credential helper](https://git-scm.com/docs/gitcredentials#_custom_helpers) 241 | (#41, xPMo) 242 | 243 | ### Changed 244 | 245 | * Also disable swap and viminfo files when using `EDITOR=nvim` (#70, Dophin2009) 246 | 247 | ### Fixed 248 | 249 | * Properly handle a couple folder name edge cases in `bin/rbw-fzf` (#66, 250 | mattalexx) 251 | * Support passing command line arguments via `EDITOR`/`VISUAL` (#61, xPMo) 252 | 253 | ## [1.4.0] - 2021-10-27 254 | 255 | ### Fixed 256 | 257 | * Add `rbw register` to allow `rbw` to work with the official Bitwarden server 258 | again - see the README for details (#71) 259 | 260 | ## [1.3.0] - 2021-07-05 261 | 262 | ### Changed 263 | 264 | * Use the system's native TLS certificate store when making HTTP requests. 265 | 266 | ### Fixed 267 | 268 | * Correctly handle TOTP secret strings that copy with spaces (#56, TamasBarta, niki-on-github) 269 | 270 | ## [1.2.0] - 2021-04-18 271 | 272 | ### Added 273 | 274 | * Shell completion for bash, zsh, and fish (#18) 275 | 276 | ### Changed 277 | 278 | * Prebuilt binaries are now statically linked using musl, to prevent glibc 279 | version issues once and for all (#47) 280 | * Standardize on RustCrypto in preference to ring or openssl 281 | 282 | ### Fixed 283 | 284 | * `rbw generate` can now choose the same character more than once (#54, rjc) 285 | * Improved handling of password history for entries with no password (#51/#53, 286 | simias) 287 | * Fix configuring base_url with a trailing slash when using a self-hosted 288 | version of the official bitwarden server (#49, phylor) 289 | 290 | ## [1.1.2] - 2021-03-06 291 | 292 | ### Fixed 293 | 294 | * Send warnings about failure to disable PTRACE_ATTACH to the agent logs rather 295 | than stderr 296 | 297 | ## [1.1.1] - 2021-03-05 298 | 299 | ### Fixed 300 | 301 | * Fix non-Linux platforms (#44, rjc) 302 | 303 | ## [1.1.0] - 2021-03-02 304 | 305 | ### Added 306 | 307 | * You can now `rbw config set pinentry pinentry-curses` to change the pinentry 308 | program used by `rbw` (#39, djmattyg007) 309 | 310 | ### Changed 311 | 312 | * On Linux, the `rbw-agent` process can no longer be attached to by debuggers, 313 | and no longer produces core dumps (#42, oranenj) 314 | * Suggest rotating the user's encryption key if we see an old cipherstring type 315 | (#40, rjc) 316 | * Prefer the value of `$VISUAL` when trying to find an editor to run, before 317 | falling back to `$EDITOR` (#43, rjc) 318 | 319 | ## [1.0.0] - 2021-02-21 320 | 321 | ### Added 322 | 323 | * Clarified the maintenance policy for this project in the README 324 | 325 | ### Fixed 326 | 327 | * Stop hardcoding /tmp when using the fallback runtime directory (#37, pschmitt) 328 | * Fix `rbw edit` clearing the match detection setting for websites associated 329 | with the edited password (#34, AdmiralNemo) 330 | * Note that you will need to `rbw sync` after upgrading and before running 331 | `rbw edit` in order to correctly update the local database. 332 | 333 | ## [0.5.2] - 2020-12-02 334 | 335 | ### Fixed 336 | 337 | * `rbw` should once again be usable on systems with glibc-2.28 (such as Debian 338 | stable). 339 | 340 | ## [0.5.1] - 2020-12-02 341 | 342 | ### Fixed 343 | 344 | * `rbw code` now always displays the correct number of digits. (#25, Tyilo) 345 | * TOTP secrets can now also be supplied as `otpauth` urls. 346 | * Logging into bitwarden.com with 2fa enabled now works again. 347 | 348 | ## [0.5.0] - 2020-10-12 349 | 350 | ### Added 351 | 352 | * Add support for cipherstring type 6 (fixes some vaults using an older format 353 | for organizations data). (Jake Swenson) 354 | * `rbw get --full` now displays URIs, TOTP secrets, and custom fields. 355 | * Add `rbw code` for generating TOTP codes based on secrets stored in 356 | Bitwarden. 357 | * Add `rbw unlocked` which will exit with success if the agent is unlocked and 358 | failure if the agent is locked. 359 | 360 | ### Fixed 361 | 362 | * Don't display deleted items (#22, GnunuX) 363 | 364 | ## [0.4.6] - 2020-07-11 365 | 366 | ### Fixed 367 | 368 | * Login passwords containing a `%` now work properly (albakham). 369 | 370 | ## [0.4.5] - 2020-07-11 371 | 372 | ### Fixed 373 | 374 | * The pinentry window now no longer times out. 375 | 376 | ## [0.4.4] - 2020-06-23 377 | 378 | ### Fixed 379 | 380 | * Fix regression in `rbw get` when not specifying a folder. 381 | 382 | ## [0.4.3] - 2020-06-23 383 | 384 | ### Added 385 | 386 | * `rbw get` now accepts a `--folder` option to pick the folder to search in. 387 | 388 | ### Changed 389 | 390 | * `rbw get --full` now also includes the username. (Jarkko Oranen) 391 | 392 | ### Fixed 393 | 394 | * `rbw` should now be usable on systems with glibc-2.28 (such as Debian 395 | stable). (incredible-machine) 396 | 397 | ## [0.4.2] - 2020-05-30 398 | 399 | ### Fixed 400 | 401 | * `rbw` now no longer requires the `XDG_RUNTIME_DIR` environment variable to be 402 | set. 403 | 404 | ## [0.4.1] - 2020-05-28 405 | 406 | ### Fixed 407 | 408 | * More improved error messages. 409 | 410 | ## [0.4.0] - 2020-05-28 411 | 412 | ### Added 413 | 414 | * Authenticator-based two-step login is now supported. 415 | 416 | ### Fixed 417 | 418 | * Correctly handle password retries when entering an invalid password on the 419 | official Bitwarden server. 420 | * Fix hang when giving an empty string to pinentry. 421 | * The error message from the server is now shown when logging in fails. 422 | 423 | ## [0.3.5] - 2020-05-25 424 | 425 | ### Fixed 426 | 427 | * Terminal-based pinentry methods should now work correctly (Glandos). 428 | * Further error message improvements. 429 | 430 | ## [0.3.4] - 2020-05-24 431 | 432 | ### Fixed 433 | 434 | * Handle edge case where a URI entry is set for a cipher but that entry has a 435 | null URI string (Adrien CLERC). 436 | 437 | ## [0.3.3] - 2020-05-23 438 | 439 | ### Fixed 440 | 441 | * Set the correct default lock timeout when first creating the config file. 442 | * Add a more useful error when `rbw` is run without being configured first. 443 | * Don't throw an error when attempting to configure the base url before 444 | configuring the email. 445 | * More improvements to error output. 446 | 447 | ## [0.3.2] - 2020-05-23 448 | 449 | ### Fixed 450 | 451 | * Improve warning and error output a bit. 452 | 453 | ## [0.3.1] - 2020-05-23 454 | 455 | ### Fixed 456 | 457 | * Fix option parsing for `rbw list --fields` and `rbw --uri` 458 | which was inadvertently broken in the previous release. 459 | 460 | ## [0.3.0] - 2020-05-22 461 | 462 | ### Fixed 463 | 464 | * Better error message if the agent fails to start after daemonizing. 465 | * Always automatically upgrade rbw-agent on new releases. 466 | * Changing configuration now automatically drops in-memory keys (this should 467 | avoid errors when switching between different servers or accounts). 468 | * Disallow setting `lock_timeout` to `0`, since this will cause the agent to 469 | immediately drop the decrypted keys before they can be used for decryption, 470 | even within a single run of the `rbw` client. 471 | 472 | ## [0.2.2] - 2020-05-17 473 | 474 | ### Fixed 475 | 476 | * Fix syncing from the official Bitwarden server (thanks the_fdw). 477 | 478 | ### Added 479 | 480 | * Added a couple example scripts to the repository for searching using fzf and 481 | rofi. Contributions and improvements welcome! 482 | 483 | ## [0.2.1] - 2020-05-03 484 | 485 | ### Fixed 486 | 487 | * Properly maintain folder and URIs when editing an entry. 488 | 489 | ## [0.2.0] - 2020-05-03 490 | 491 | ### Added 492 | 493 | * Multi-server support - you can now switch between multiple different 494 | bitwarden servers with `rbw config set base_url` without needing to 495 | redownload the password database each time. 496 | * `rbw config unset` to reset configuration items back to the default 497 | * `rbw list` and `rbw get` now support card, identity, and secure note entry 498 | types 499 | 500 | ### Fixed 501 | 502 | * `rbw` is now able to decrypt secrets from organizations you are a member of. 503 | * `rbw stop-agent` now waits for the agent to exit before returning. 504 | 505 | ### Changed 506 | 507 | * Move to the `ring` crate for a bunch of the cryptographic functionality. 508 | * The agent protocol is now versioned, to allow for seamless updates. 509 | 510 | ## [0.1.1] - 2020-05-01 511 | 512 | ### Fixed 513 | 514 | * Some packaging changes. 515 | 516 | ## [0.1.0] - 2020-04-20 517 | 518 | ### Added 519 | 520 | * Initial release 521 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rbw" 3 | version = "1.13.2" 4 | authors = ["Jesse Luehrs "] 5 | edition = "2021" 6 | rust-version = "1.80.0" 7 | 8 | description = "Unofficial Bitwarden CLI" 9 | repository = "https://git.tozt.net/rbw" 10 | readme = "README.md" 11 | keywords = ["bitwarden"] 12 | categories = ["command-line-utilities", "cryptography"] 13 | license = "MIT" 14 | include = ["src/**/*", "bin/**/*", "LICENSE", "README.md", "CHANGELOG.md"] 15 | 16 | [dependencies] 17 | aes = "0.8.4" 18 | anyhow = "1.0.98" 19 | argon2 = "0.5.3" 20 | arrayvec = "0.7.6" 21 | axum = "0.8.4" 22 | base32 = "0.5.1" 23 | base64 = "0.22.1" 24 | block-padding = "0.3.3" 25 | cbc = { version = "0.1.2", features = ["alloc", "std"] } 26 | clap_complete = "4.5.50" 27 | clap = { version = "4.5.38", features = ["wrap_help", "derive"] } 28 | daemonize = "0.5.0" 29 | # TODO: directories 5.0.1 uses MPL code, which isn't license-compatible 30 | # we should switch to something else at some point 31 | directories = "=5.0.0" 32 | env_logger = "0.11.8" 33 | futures = "0.3.31" 34 | futures-channel = "0.3.31" 35 | futures-util = "0.3.31" 36 | hkdf = "0.12.4" 37 | hmac = { version = "0.12.1", features = ["std"] } 38 | humantime = "2.2.0" 39 | is-terminal = "0.4.16" 40 | libc = "0.2.172" 41 | log = "0.4.27" 42 | open = "5.3.2" 43 | pbkdf2 = "0.12.2" 44 | percent-encoding = "2.3.1" 45 | pkcs8 = "0.10.2" 46 | rand = "0.8.5" 47 | regex = "1.11.1" 48 | region = "3.0.2" 49 | reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "json", "rustls-tls-native-roots"] } 50 | rmpv = "1.3.0" 51 | rsa = "0.9.8" 52 | rustix = { version = "0.38.44", features = ["termios", "procfs", "process", "pipe"] } 53 | serde_json = "1.0.140" 54 | serde_path_to_error = "0.1.17" 55 | serde_repr = "0.1.20" 56 | serde = { version = "1.0.219", features = ["derive"] } 57 | sha1 = "0.10.6" 58 | sha2 = "0.10.9" 59 | tempfile = "3.15.0" 60 | terminal_size = "0.4.1" 61 | textwrap = "0.16.2" 62 | thiserror = "1.0.69" 63 | tokio-stream = { version = "0.1.17", features = ["net"] } 64 | tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots", "url"] } 65 | tokio = { version = "1.45.0", features = ["full"] } 66 | totp-rs = { version = "5.7.0", features = [ "steam" ] } 67 | url = "2.5.4" 68 | urlencoding = "2.1.3" 69 | uuid = { version = "1.12.1", features = ["v4"] } 70 | zeroize = "1.8.1" 71 | 72 | arboard = { version = "3.5", default-features = false, features = ["wayland-data-control"], optional = true } 73 | 74 | [features] 75 | default = ["clipboard"] 76 | clipboard = ["arboard"] 77 | 78 | [lints.clippy] 79 | cargo = { level = "warn", priority = -1 } 80 | pedantic = { level = "warn", priority = -1 } 81 | nursery = { level = "warn", priority = -1 } 82 | as_conversions = "warn" 83 | get_unwrap = "warn" 84 | cognitive_complexity = "allow" 85 | missing_const_for_fn = "allow" 86 | similar_names = "allow" 87 | struct_excessive_bools = "allow" 88 | too_many_arguments = "allow" 89 | too_many_lines = "allow" 90 | type_complexity = "allow" 91 | multiple_crate_versions = "allow" 92 | large_enum_variant = "allow" 93 | must_use_candidate = "allow" 94 | missing_errors_doc = "allow" 95 | missing_panics_doc = "allow" 96 | significant_drop_tightening = "allow" 97 | 98 | [package.metadata.deb] 99 | depends = "pinentry" 100 | license-file = ["LICENSE"] 101 | assets = [ 102 | ["target/release/rbw", "usr/bin/", "755"], 103 | ["target/release/rbw-agent", "usr/bin/", "755"], 104 | ["target/release/completion/bash", "usr/share/bash-completion/completions/rbw", "644"], 105 | ["target/release/completion/zsh", "usr/share/zsh/vendor-completions/_rbw", "644"], 106 | ["target/release/completion/fish", "usr/share/fish/completions/rbw.fish", "644"], 107 | ["README.md", "usr/share/doc/rbw/README", "644"], 108 | ] 109 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is Copyright (c) 2021 by Jesse Luehrs. 2 | 3 | This is free software, licensed under: 4 | 5 | The MIT (X11) License 6 | 7 | The MIT License 8 | 9 | Permission is hereby granted, free of charge, to any person 10 | obtaining a copy of this software and associated 11 | documentation files (the "Software"), to deal in the Software 12 | without restriction, including without limitation the rights to 13 | use, copy, modify, merge, publish, distribute, sublicense, 14 | and/or sell copies of the Software, and to permit persons to 15 | whom the Software is furnished to do so, subject to the 16 | following conditions: 17 | 18 | The above copyright notice and this permission notice shall 19 | be included in all copies or substantial portions of the 20 | Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT 23 | WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 24 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 25 | MERCHANTABILITY, FITNESS FOR A PARTICULAR 26 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT 27 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 28 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 30 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 31 | CONNECTION WITH THE SOFTWARE OR THE USE OR 32 | OTHER DEALINGS IN THE SOFTWARE. 33 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME = $(shell cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].name') 2 | VERSION = $(shell cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version') 3 | 4 | DEB_PACKAGE = $(NAME)_$(VERSION)_amd64.deb 5 | TGZ_PACKAGE = $(NAME)_$(VERSION)_linux_amd64.tar.gz 6 | 7 | all: build 8 | .PHONY: all 9 | 10 | build: 11 | @cargo build --all-targets --target x86_64-unknown-linux-musl 12 | .PHONY: build 13 | 14 | release: 15 | @cargo build --release --all-targets --target x86_64-unknown-linux-musl 16 | .PHONY: release 17 | 18 | test: 19 | @RUST_BACKTRACE=1 cargo test --target x86_64-unknown-linux-musl 20 | .PHONY: test 21 | 22 | check: 23 | @cargo check --all-targets --target x86_64-unknown-linux-musl 24 | .PHONY: check 25 | 26 | doc: 27 | @cargo doc --workspace 28 | .PHONY: doc 29 | 30 | clean: 31 | @rm -rf *.log pkg 32 | .PHONY: clean 33 | 34 | cleanall: clean 35 | @cargo clean 36 | .PHONY: cleanall 37 | 38 | completion: release 39 | @mkdir -p target/x86_64-unknown-linux-musl/release/completion 40 | @./target/x86_64-unknown-linux-musl/release/rbw gen-completions bash > target/x86_64-unknown-linux-musl/release/completion/bash 41 | @./target/x86_64-unknown-linux-musl/release/rbw gen-completions zsh > target/x86_64-unknown-linux-musl/release/completion/zsh 42 | @./target/x86_64-unknown-linux-musl/release/rbw gen-completions fish > target/x86_64-unknown-linux-musl/release/completion/fish 43 | .PHONY: completion 44 | 45 | package: pkg/$(DEB_PACKAGE) pkg/$(TGZ_PACKAGE) 46 | .PHONY: package 47 | 48 | pkg: 49 | @mkdir pkg 50 | 51 | pkg/$(DEB_PACKAGE): release completion | pkg 52 | @cargo deb --no-build --target x86_64-unknown-linux-musl && mv target/x86_64-unknown-linux-musl/debian/$(DEB_PACKAGE) pkg 53 | 54 | pkg/$(DEB_PACKAGE).minisig: pkg/$(DEB_PACKAGE) 55 | @minisign -Sm $< 56 | 57 | pkg/$(TGZ_PACKAGE): release completion | pkg 58 | @tar czf $@ -C target/x86_64-unknown-linux-musl/release rbw rbw-agent completion 59 | 60 | release-dir-deb: 61 | @ssh tozt.net mkdir -p releases/rbw/deb 62 | .PHONY: release-dir-deb 63 | 64 | publish: publish-crates-io publish-git-tags publish-deb publish-github 65 | .PHONY: publish 66 | 67 | publish-crates-io: test 68 | @cargo publish 69 | .PHONY: publish-crates-io 70 | 71 | # force shell instead of exec to work around 72 | # https://savannah.gnu.org/bugs/?57962 since i have ~/.bin/git as a directory 73 | publish-git-tags: test 74 | @:; git tag $(VERSION) 75 | @:; git push --tags 76 | .PHONY: publish-git-tags 77 | 78 | publish-deb: test pkg/$(DEB_PACKAGE) pkg/$(DEB_PACKAGE).minisig release-dir-deb 79 | @scp pkg/$(DEB_PACKAGE) pkg/$(DEB_PACKAGE).minisig tozt.net:releases/rbw/deb 80 | .PHONY: publish-deb 81 | 82 | publish-github: test pkg/$(TGZ_PACKAGE) 83 | @perl -nle'print if /^## \Q[$(VERSION)]/../^## (?!\Q[$(VERSION)]\E)/' CHANGELOG.md | head -n-2 | gh release create $(VERSION) --verify-tag --notes-file - pkg/$(TGZ_PACKAGE) 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rbw 2 | 3 | This is an unofficial command line client for 4 | [Bitwarden](https://bitwarden.com/). Although it does come with its own 5 | [command line client](https://help.bitwarden.com/article/cli/), this client is 6 | limited by being stateless - to use it, you're required to manually lock and 7 | unlock the client, and pass the temporary keys around in environment variables, 8 | which makes it very difficult to use. This client avoids this problem by 9 | maintaining a background process which is able to hold the keys in memory, 10 | similar to the way that `ssh-agent` or `gpg-agent` work. This allows the client 11 | to be used in a much simpler way, with the background agent taking care of 12 | maintaining the necessary state. 13 | 14 | ## Maintenance 15 | 16 | I consider `rbw` to be essentially feature-complete for me at this point. While 17 | I still use it on a daily basis, and will continue to fix regressions as they 18 | occur, I am unlikely to spend time implementing new features on my own. If you 19 | would like to see new functionality in `rbw`, I am more than happy to review 20 | and merge pull requests implementing those features. 21 | 22 | ## Installation 23 | 24 | ### Arch Linux 25 | 26 | `rbw` is available in the [extra 27 | repository](https://archlinux.org/packages/extra/x86_64/rbw/). 28 | Alternatively, you can install 29 | [`rbw-git`](https://aur.archlinux.org/packages/rbw-git/) from the AUR, which 30 | will always build from the latest master commit. 31 | 32 | ### Debian/Ubuntu 33 | 34 | You can download a Debian package from 35 | [https://git.tozt.net/rbw/releases/deb/ 36 | ](https://git.tozt.net/rbw/releases/deb/). The packages are signed by 37 | [`minisign`](https://github.com/jedisct1/minisign), and can be verified using 38 | the public key `RWTM0AZ5RpROOfAIWx1HvYQ6pw1+FKwN6526UFTKNImP/Hz3ynCFst3r`. 39 | 40 | ### Fedora/EPEL 41 | 42 | `rbw` is available in [Fedora and EPEL 9](https://bodhi.fedoraproject.org/updates/?packages=rust-rbw) 43 | (for RHEL and compatible distributions). 44 | 45 | You can install it using `sudo dnf install rbw`. 46 | 47 | ### Homebrew 48 | 49 | `rbw` is available in the [Homebrew repository](https://formulae.brew.sh/formula/rbw). You can install it via `brew install rbw`. 50 | 51 | ### Nix 52 | 53 | `rbw` is available in the 54 | [NixOS repository](https://search.nixos.org/packages?show=rbw). You can try 55 | it out via `nix-shell -p rbw`. 56 | 57 | ### Alpine 58 | 59 | `rbw` is available in the [testing repository](https://pkgs.alpinelinux.org/packages?name=rbw). 60 | If you are not using the `edge` version of alpine you have to [enable the repository manually](https://wiki.alpinelinux.org/wiki/Repositories#Testing). 61 | 62 | ### Other 63 | 64 | With a working Rust installation, `rbw` can be installed via `cargo install 65 | --locked rbw`. This requires that the 66 | [`pinentry`](https://www.gnupg.org/related_software/pinentry/index.en.html) 67 | program is installed (to display password prompts). 68 | 69 | ## Configuration 70 | 71 | Configuration options are set using the `rbw config` command. Available 72 | configuration options: 73 | 74 | * `email`: The email address to use as the account name when logging into the 75 | Bitwarden server. Required. 76 | * `sso_id`: The SSO organization ID. Defaults to regular login process if unset. 77 | * `base_url`: The URL of the Bitwarden server to use. Defaults to the official 78 | server at `https://api.bitwarden.com/` if unset. 79 | * `identity_url`: The URL of the Bitwarden identity server to use. If unset, 80 | will use the `/identity` path on the configured `base_url`, or 81 | `https://identity.bitwarden.com/` if no `base_url` is set. 82 | * `ui_url`: The URL of the Bitwarden UI to use. If unset, 83 | will default to `https://vault.bitwarden.com/`. 84 | * `notifications_url`: The URL of the Bitwarden notifications server to use. 85 | If unset, will use the `/notifications` path on the configured `base_url`, 86 | or `https://notifications.bitwarden.com/` if no `base_url` is set. 87 | * `lock_timeout`: The number of seconds to keep the master keys in memory for 88 | before requiring the password to be entered again. Defaults to `3600` (one 89 | hour). 90 | * `sync_interval`: `rbw` will automatically sync the database from the server 91 | at an interval of this many seconds, while the agent is running. Setting 92 | this value to `0` disables this behavior. Defaults to `3600` (one hour). 93 | * `pinentry`: The 94 | [pinentry](https://www.gnupg.org/related_software/pinentry/index.html) 95 | executable to use. Defaults to `pinentry`. 96 | 97 | ### Profiles 98 | 99 | `rbw` supports different configuration profiles, which can be switched 100 | between by using the `RBW_PROFILE` environment variable. Setting it to a name 101 | (for example, `RBW_PROFILE=work` or `RBW_PROFILE=personal`) can be used to 102 | switch between several different vaults - each will use its own separate 103 | configuration, local vault, and agent. 104 | 105 | ## Usage 106 | 107 | Commands can generally be used directly, and will handle logging in or 108 | unlocking as necessary. For instance, running `rbw ls` will run `rbw unlock` to 109 | unlock the password database before generating the list of entries (but will 110 | not attempt to log in to the server), `rbw sync` will automatically run `rbw 111 | login` to log in to the server before downloading the password database (but 112 | will not unlock the database), and `rbw add` will do both. 113 | 114 | Logging into the server and unlocking the database will only be done as 115 | necessary, so running `rbw login` when you are already logged in will do 116 | nothing, and similarly for `rbw unlock`. If necessary, you can explicitly log 117 | out by running `rbw purge`, and you can explicitly lock the database by running 118 | `rbw lock` or `rbw stop-agent`. 119 | 120 | `rbw help` can be used to get more information about the available 121 | functionality. 122 | 123 | Run `rbw get ` to get your passwords. If you also want to get the username 124 | or the note associated, you can use the flag `--full`. You can also use the flag 125 | `--field={field}` to get whatever default or custom field you want. The `--raw` 126 | flag will show the output as JSON. In addition to matching against the name, 127 | you can pass a UUID as the name to search for the entry with that id, or a 128 | URL to search for an entry with a matching website entry. 129 | 130 | *Note to users of the official Bitwarden server (at bitwarden.com)*: The 131 | official server has a tendency to detect command line traffic as bot traffic 132 | (see [this issue](https://github.com/bitwarden/cli/issues/383) for details). In 133 | order to use `rbw` with the official Bitwarden server, you will need to first 134 | run `rbw register` to register each device using `rbw` with the Bitwarden 135 | server. This will prompt you for your personal API key which you can find using 136 | the instructions [here](https://bitwarden.com/help/article/personal-api-key/). 137 | 138 | ## Related projects 139 | 140 | * [rofi-rbw](https://github.com/fdw/rofi-rbw): A rofi frontend for Bitwarden 141 | * [bw-ssh](https://framagit.org/Glandos/bw-ssh/): Manage SSH key passphrases in Bitwarden 142 | * [rbw-menu](https://github.com/rbuchberger/rbw-menu): Tiny menu picker for rbw 143 | * [ulauncher-rbw](https://0xacab.org/varac-projects/ulauncher-rbw): [Ulauncher](https://ulauncher.io/) rbw extension 144 | -------------------------------------------------------------------------------- /bin/git-credential-rbw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -f 3 | 4 | [ "$1" = get ] || exit 5 | 6 | while read -r line; do 7 | case $line in 8 | protocol=*) 9 | protocol=${line#*=} ;; 10 | host=*) 11 | host=${line#*=} ;; 12 | username=*) 13 | user=${line#*=} ;; 14 | path=*) 15 | path=${line#*=} ;; 16 | esac 17 | done 18 | 19 | output= 20 | #shellcheck disable=2154 21 | for arg in \ 22 | "${protocol:+$protocol://}$host${path:+/$path}" \ 23 | "$host" \ 24 | "${host2=${host%.*}}" \ 25 | "${host2#*.}" 26 | do 27 | # exit on first good result 28 | [ -n "$user" ] && output=$(rbw get --full "$arg" "$user") && break 29 | output=$(rbw get --full "$arg") && break 30 | done || exit 31 | 32 | printf '%s\n' "$output" | sed -n ' 33 | 1{ s/^/password=/p } 34 | s/^Username: /username=/p 35 | s/^URI: /host=/p 36 | ' 37 | -------------------------------------------------------------------------------- /bin/pass-import: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | EDITOR=$(mktemp) 5 | trap 'rm -f $EDITOR' EXIT 6 | cat > "$EDITOR" <<'EOF' 7 | #!/bin/sh 8 | cat > "$1" 9 | EOF 10 | chmod 700 "$EDITOR" 11 | export EDITOR 12 | 13 | for entry in $(pass git ls-files | grep '\.gpg$' | sed 's/\.gpg$//'); do 14 | echo "$entry" 15 | pw=$(pass show "$entry") 16 | 17 | user="${entry##*/}" 18 | full_name="${entry%/*}" 19 | if echo "$full_name" | grep -q /; then 20 | name="${full_name##*/}" 21 | folder="${full_name%/*}" 22 | else 23 | name="$full_name" 24 | folder="" 25 | fi 26 | if echo "$name" | grep -q '\.'; then 27 | if [ -z "$folder" ]; then 28 | echo "$pw" | rbw add --uri "$name" "$name" "$user" 29 | else 30 | echo "$pw" | rbw add --uri "$name" --folder "$folder" "$name" "$user" 31 | fi 32 | else 33 | if [ -z "$folder" ]; then 34 | echo "$pw" | rbw add "$name" "$user" 35 | else 36 | echo "$pw" | rbw add --folder "$folder" "$name" "$user" 37 | fi 38 | fi 39 | done 40 | -------------------------------------------------------------------------------- /bin/rbw-fzf: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eu 3 | set -o pipefail 4 | 5 | rbw ls --fields name,user,folder | \ 6 | perl -plE'/^([^\t]*)\t([^\t]*)\t([^\t]*)$/; $_ = join("/", grep { length } ($3, $1, $2)) . "\0$_"' | \ 7 | sort | \ 8 | fzf --with-nth=1 -d '\x00' | \ 9 | perl -ple'/^([^\0]*)\0([^\t]*)\t([^\t]*)\t([^\t]*)$/; $_ = "$2 $3"; $_ .= " --folder=\"$4\"" if length $4' | \ 10 | xargs -r rbw get 11 | -------------------------------------------------------------------------------- /bin/rbw-pinentry-keyring: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | [[ -z "${RBW_PROFILE}" ]] && rbw_profile='rbw' || rbw_profile="rbw-${RBW_PROFILE}" 4 | 5 | set -eEuo pipefail 6 | 7 | function help() { 8 | cat </dev/null 2>&1 73 | fi 74 | fi 75 | 76 | printf 'D %s\n' "$secret_value" 77 | echo 'OK' 78 | else 79 | cmd="SETTITLE $title\n" 80 | cmd+="SETPROMPT $prompt\n" 81 | cmd+="SETDESC $desc\n" 82 | cmd+="GETPIN\n" 83 | 84 | secret_value="$(printf "$cmd" | pinentry "$@" | grep -E "^D " | cut -c3-)" 85 | 86 | printf 'D %s\n' "$secret_value" 87 | echo 'OK' 88 | fi 89 | ;; 90 | BYE) 91 | exit 92 | ;; 93 | *) 94 | echo 'ERR Unknown command' 95 | ;; 96 | esac 97 | done 98 | } 99 | 100 | command="$1" 101 | case "$command" in 102 | -h|--help|help) 103 | help 104 | ;; 105 | -c|--clear|clear) 106 | clear 107 | ;; 108 | *) 109 | getpin "$@" 110 | ;; 111 | esac 112 | -------------------------------------------------------------------------------- /bin/rbw-rofi: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eu 3 | set -o pipefail 4 | 5 | rbw ls --fields folder,name,user | sed 's/\t/\//g' | sort | rofi -dmenu | sed 's/^[^\/]*\///' | sed 's/\// /' | xargs -r rbw get | xclip -l 1 6 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | [graph] 2 | targets = [ 3 | { triple = "x86_64-unknown-linux-musl" }, 4 | { triple = "x86_64-unknown-linux-gnu" }, 5 | { triple = "x86_64-apple-darwin" }, 6 | { triple = "aarch64-apple-darwin" }, 7 | ] 8 | 9 | [advisories] 10 | version = 2 11 | yanked = "deny" 12 | ignore = [ 13 | # this is a timing attack against using the rsa crate for encryption, but 14 | # we only use rsa decryption here 15 | "RUSTSEC-2023-0071", 16 | # https://github.com/3Hren/msgpack-rust/pull/366 17 | "RUSTSEC-2024-0436", 18 | ] 19 | 20 | [bans] 21 | multiple-versions = "deny" 22 | wildcards = "deny" 23 | deny = [ 24 | { name = "openssl-sys" }, 25 | ] 26 | skip = [ 27 | # https://github.com/darfink/region-rs/pull/25 28 | { name = "bitflags", version = "1.3.2" }, 29 | { name = "bitflags", version = "2.4.1" }, 30 | ] 31 | 32 | [licenses] 33 | version = 2 34 | allow = [ 35 | "MIT", 36 | "BSD-2-Clause", 37 | "BSD-3-Clause", 38 | "Apache-2.0", 39 | "ISC", 40 | "Unicode-3.0", 41 | ] 42 | exceptions = [ 43 | { name = "ring", allow = ["OpenSSL", "MIT", "ISC"] } 44 | ] 45 | 46 | [[licenses.clarify]] 47 | name = "ring" 48 | version = "*" 49 | expression = "MIT AND ISC AND OpenSSL" 50 | license-files = [ 51 | { path = "LICENSE", hash = 0xbd0eed23 } 52 | ] 53 | 54 | [[licenses.clarify]] 55 | name = "encoding_rs" 56 | version = "*" 57 | expression = "(Apache-2.0 OR MIT) AND BSD-3-Clause" 58 | license-files = [ 59 | { path = "COPYRIGHT", hash = 0x39f8ad31 } 60 | ] 61 | -------------------------------------------------------------------------------- /src/actions.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub async fn register( 4 | email: &str, 5 | apikey: crate::locked::ApiKey, 6 | ) -> Result<()> { 7 | let (client, config) = api_client_async().await?; 8 | 9 | client 10 | .register(email, &crate::config::device_id(&config).await?, &apikey) 11 | .await?; 12 | 13 | Ok(()) 14 | } 15 | 16 | pub async fn login( 17 | email: &str, 18 | password: crate::locked::Password, 19 | two_factor_token: Option<&str>, 20 | two_factor_provider: Option, 21 | ) -> Result<( 22 | String, 23 | String, 24 | crate::api::KdfType, 25 | u32, 26 | Option, 27 | Option, 28 | String, 29 | )> { 30 | let (client, config) = api_client_async().await?; 31 | let (kdf, iterations, memory, parallelism) = 32 | client.prelogin(email).await?; 33 | 34 | let identity = crate::identity::Identity::new( 35 | email, 36 | &password, 37 | kdf, 38 | iterations, 39 | memory, 40 | parallelism, 41 | )?; 42 | let (access_token, refresh_token, protected_key) = client 43 | .login( 44 | email, 45 | config.sso_id.as_deref(), 46 | &crate::config::device_id(&config).await?, 47 | &identity.master_password_hash, 48 | two_factor_token, 49 | two_factor_provider, 50 | ) 51 | .await?; 52 | 53 | Ok(( 54 | access_token, 55 | refresh_token, 56 | kdf, 57 | iterations, 58 | memory, 59 | parallelism, 60 | protected_key, 61 | )) 62 | } 63 | 64 | pub fn unlock( 65 | email: &str, 66 | password: &crate::locked::Password, 67 | kdf: crate::api::KdfType, 68 | iterations: u32, 69 | memory: Option, 70 | parallelism: Option, 71 | protected_key: &str, 72 | protected_private_key: &str, 73 | protected_org_keys: &std::collections::HashMap, 74 | ) -> Result<( 75 | crate::locked::Keys, 76 | std::collections::HashMap, 77 | )> { 78 | let identity = crate::identity::Identity::new( 79 | email, 80 | password, 81 | kdf, 82 | iterations, 83 | memory, 84 | parallelism, 85 | )?; 86 | 87 | let protected_key = 88 | crate::cipherstring::CipherString::new(protected_key)?; 89 | let key = match protected_key.decrypt_locked_symmetric(&identity.keys) { 90 | Ok(master_keys) => crate::locked::Keys::new(master_keys), 91 | Err(Error::InvalidMac) => { 92 | return Err(Error::IncorrectPassword { 93 | message: "Password is incorrect. Try again.".to_string(), 94 | }) 95 | } 96 | Err(e) => return Err(e), 97 | }; 98 | 99 | let protected_private_key = 100 | crate::cipherstring::CipherString::new(protected_private_key)?; 101 | let private_key = 102 | match protected_private_key.decrypt_locked_symmetric(&key) { 103 | Ok(private_key) => crate::locked::PrivateKey::new(private_key), 104 | Err(e) => return Err(e), 105 | }; 106 | 107 | let mut org_keys = std::collections::HashMap::new(); 108 | for (org_id, protected_org_key) in protected_org_keys { 109 | let protected_org_key = 110 | crate::cipherstring::CipherString::new(protected_org_key)?; 111 | let org_key = 112 | match protected_org_key.decrypt_locked_asymmetric(&private_key) { 113 | Ok(org_key) => crate::locked::Keys::new(org_key), 114 | Err(e) => return Err(e), 115 | }; 116 | org_keys.insert(org_id.to_string(), org_key); 117 | } 118 | 119 | Ok((key, org_keys)) 120 | } 121 | 122 | pub async fn sync( 123 | access_token: &str, 124 | refresh_token: &str, 125 | ) -> Result<( 126 | Option, 127 | ( 128 | String, 129 | String, 130 | std::collections::HashMap, 131 | Vec, 132 | ), 133 | )> { 134 | with_exchange_refresh_token_async( 135 | access_token, 136 | refresh_token, 137 | |access_token| { 138 | let access_token = access_token.to_string(); 139 | Box::pin(async move { sync_once(&access_token).await }) 140 | }, 141 | ) 142 | .await 143 | } 144 | 145 | async fn sync_once( 146 | access_token: &str, 147 | ) -> Result<( 148 | String, 149 | String, 150 | std::collections::HashMap, 151 | Vec, 152 | )> { 153 | let (client, _) = api_client_async().await?; 154 | client.sync(access_token).await 155 | } 156 | 157 | pub fn add( 158 | access_token: &str, 159 | refresh_token: &str, 160 | name: &str, 161 | data: &crate::db::EntryData, 162 | notes: Option<&str>, 163 | folder_id: Option<&str>, 164 | ) -> Result<(Option, ())> { 165 | with_exchange_refresh_token(access_token, refresh_token, |access_token| { 166 | add_once(access_token, name, data, notes, folder_id) 167 | }) 168 | } 169 | 170 | fn add_once( 171 | access_token: &str, 172 | name: &str, 173 | data: &crate::db::EntryData, 174 | notes: Option<&str>, 175 | folder_id: Option<&str>, 176 | ) -> Result<()> { 177 | let (client, _) = api_client()?; 178 | client.add(access_token, name, data, notes, folder_id)?; 179 | Ok(()) 180 | } 181 | 182 | pub fn edit( 183 | access_token: &str, 184 | refresh_token: &str, 185 | id: &str, 186 | org_id: Option<&str>, 187 | name: &str, 188 | data: &crate::db::EntryData, 189 | fields: &[crate::db::Field], 190 | notes: Option<&str>, 191 | folder_uuid: Option<&str>, 192 | history: &[crate::db::HistoryEntry], 193 | ) -> Result<(Option, ())> { 194 | with_exchange_refresh_token(access_token, refresh_token, |access_token| { 195 | edit_once( 196 | access_token, 197 | id, 198 | org_id, 199 | name, 200 | data, 201 | fields, 202 | notes, 203 | folder_uuid, 204 | history, 205 | ) 206 | }) 207 | } 208 | 209 | fn edit_once( 210 | access_token: &str, 211 | id: &str, 212 | org_id: Option<&str>, 213 | name: &str, 214 | data: &crate::db::EntryData, 215 | fields: &[crate::db::Field], 216 | notes: Option<&str>, 217 | folder_uuid: Option<&str>, 218 | history: &[crate::db::HistoryEntry], 219 | ) -> Result<()> { 220 | let (client, _) = api_client()?; 221 | client.edit( 222 | access_token, 223 | id, 224 | org_id, 225 | name, 226 | data, 227 | fields, 228 | notes, 229 | folder_uuid, 230 | history, 231 | )?; 232 | Ok(()) 233 | } 234 | 235 | pub fn remove( 236 | access_token: &str, 237 | refresh_token: &str, 238 | id: &str, 239 | ) -> Result<(Option, ())> { 240 | with_exchange_refresh_token(access_token, refresh_token, |access_token| { 241 | remove_once(access_token, id) 242 | }) 243 | } 244 | 245 | fn remove_once(access_token: &str, id: &str) -> Result<()> { 246 | let (client, _) = api_client()?; 247 | client.remove(access_token, id)?; 248 | Ok(()) 249 | } 250 | 251 | pub fn list_folders( 252 | access_token: &str, 253 | refresh_token: &str, 254 | ) -> Result<(Option, Vec<(String, String)>)> { 255 | with_exchange_refresh_token(access_token, refresh_token, |access_token| { 256 | list_folders_once(access_token) 257 | }) 258 | } 259 | 260 | fn list_folders_once(access_token: &str) -> Result> { 261 | let (client, _) = api_client()?; 262 | client.folders(access_token) 263 | } 264 | 265 | pub fn create_folder( 266 | access_token: &str, 267 | refresh_token: &str, 268 | name: &str, 269 | ) -> Result<(Option, String)> { 270 | with_exchange_refresh_token(access_token, refresh_token, |access_token| { 271 | create_folder_once(access_token, name) 272 | }) 273 | } 274 | 275 | fn create_folder_once(access_token: &str, name: &str) -> Result { 276 | let (client, _) = api_client()?; 277 | client.create_folder(access_token, name) 278 | } 279 | 280 | fn with_exchange_refresh_token( 281 | access_token: &str, 282 | refresh_token: &str, 283 | f: F, 284 | ) -> Result<(Option, T)> 285 | where 286 | F: Fn(&str) -> Result, 287 | { 288 | match f(access_token) { 289 | Ok(t) => Ok((None, t)), 290 | Err(Error::RequestUnauthorized) => { 291 | let access_token = exchange_refresh_token(refresh_token)?; 292 | let t = f(&access_token)?; 293 | Ok((Some(access_token), t)) 294 | } 295 | Err(e) => Err(e), 296 | } 297 | } 298 | 299 | async fn with_exchange_refresh_token_async( 300 | access_token: &str, 301 | refresh_token: &str, 302 | f: F, 303 | ) -> Result<(Option, T)> 304 | where 305 | F: Fn( 306 | &str, 307 | ) -> std::pin::Pin< 308 | Box> + Send>, 309 | > + Send 310 | + Sync, 311 | T: Send, 312 | { 313 | match f(access_token).await { 314 | Ok(t) => Ok((None, t)), 315 | Err(Error::RequestUnauthorized) => { 316 | let access_token = 317 | exchange_refresh_token_async(refresh_token).await?; 318 | let t = f(&access_token).await?; 319 | Ok((Some(access_token), t)) 320 | } 321 | Err(e) => Err(e), 322 | } 323 | } 324 | 325 | fn exchange_refresh_token(refresh_token: &str) -> Result { 326 | let (client, _) = api_client()?; 327 | client.exchange_refresh_token(refresh_token) 328 | } 329 | 330 | async fn exchange_refresh_token_async(refresh_token: &str) -> Result { 331 | let (client, _) = api_client()?; 332 | client.exchange_refresh_token_async(refresh_token).await 333 | } 334 | 335 | fn api_client() -> Result<(crate::api::Client, crate::config::Config)> { 336 | let config = crate::config::Config::load()?; 337 | let client = crate::api::Client::new( 338 | &config.base_url(), 339 | &config.identity_url(), 340 | &config.ui_url(), 341 | config.client_cert_path(), 342 | ); 343 | Ok((client, config)) 344 | } 345 | 346 | async fn api_client_async( 347 | ) -> Result<(crate::api::Client, crate::config::Config)> { 348 | let config = crate::config::Config::load_async().await?; 349 | let client = crate::api::Client::new( 350 | &config.base_url(), 351 | &config.identity_url(), 352 | &config.ui_url(), 353 | config.client_cert_path(), 354 | ); 355 | Ok((client, config)) 356 | } 357 | -------------------------------------------------------------------------------- /src/base64.rs: -------------------------------------------------------------------------------- 1 | use base64::Engine as _; 2 | 3 | pub fn encode>(input: T) -> String { 4 | base64::engine::general_purpose::STANDARD.encode(input) 5 | } 6 | 7 | pub fn encode_url_safe_no_pad>(input: T) -> String { 8 | base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input) 9 | } 10 | 11 | pub fn decode>( 12 | input: T, 13 | ) -> Result, base64::DecodeError> { 14 | base64::engine::general_purpose::STANDARD.decode(input) 15 | } 16 | -------------------------------------------------------------------------------- /src/bin/rbw-agent/actions.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context as _; 2 | 3 | pub async fn register( 4 | sock: &mut crate::sock::Sock, 5 | environment: &rbw::protocol::Environment, 6 | ) -> anyhow::Result<()> { 7 | let db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new()); 8 | 9 | if db.needs_login() { 10 | let url_str = config_base_url().await?; 11 | let url = reqwest::Url::parse(&url_str) 12 | .context("failed to parse base url")?; 13 | let Some(host) = url.host_str() else { 14 | return Err(anyhow::anyhow!( 15 | "couldn't find host in rbw base url {}", 16 | url_str 17 | )); 18 | }; 19 | 20 | let email = config_email().await?; 21 | 22 | let mut err_msg = None; 23 | for i in 1_u8..=3 { 24 | let err = if i > 1 { 25 | // this unwrap is safe because we only ever continue the loop 26 | // if we have set err_msg 27 | Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) 28 | } else { 29 | None 30 | }; 31 | let client_id = rbw::pinentry::getpin( 32 | &config_pinentry().await?, 33 | "API key client__id", 34 | &format!("Log in to {host}"), 35 | err.as_deref(), 36 | environment, 37 | false, 38 | ) 39 | .await 40 | .context("failed to read client_id from pinentry")?; 41 | let client_secret = rbw::pinentry::getpin( 42 | &config_pinentry().await?, 43 | "API key client__secret", 44 | &format!("Log in to {host}"), 45 | err.as_deref(), 46 | environment, 47 | false, 48 | ) 49 | .await 50 | .context("failed to read client_secret from pinentry")?; 51 | let apikey = rbw::locked::ApiKey::new(client_id, client_secret); 52 | match rbw::actions::register(&email, apikey.clone()).await { 53 | Ok(()) => { 54 | break; 55 | } 56 | Err(rbw::error::Error::IncorrectPassword { message }) => { 57 | if i == 3 { 58 | return Err(rbw::error::Error::IncorrectPassword { 59 | message, 60 | }) 61 | .context("failed to log in to bitwarden instance"); 62 | } 63 | err_msg = Some(message); 64 | } 65 | Err(e) => { 66 | return Err(e) 67 | .context("failed to log in to bitwarden instance") 68 | } 69 | } 70 | } 71 | } 72 | 73 | respond_ack(sock).await?; 74 | 75 | Ok(()) 76 | } 77 | 78 | pub async fn login( 79 | sock: &mut crate::sock::Sock, 80 | state: std::sync::Arc>, 81 | environment: &rbw::protocol::Environment, 82 | ) -> anyhow::Result<()> { 83 | let db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new()); 84 | 85 | if db.needs_login() { 86 | let url_str = config_base_url().await?; 87 | let url = reqwest::Url::parse(&url_str) 88 | .context("failed to parse base url")?; 89 | let Some(host) = url.host_str() else { 90 | return Err(anyhow::anyhow!( 91 | "couldn't find host in rbw base url {}", 92 | url_str 93 | )); 94 | }; 95 | 96 | let email = config_email().await?; 97 | 98 | let mut err_msg = None; 99 | 'attempts: for i in 1_u8..=3 { 100 | let err = if i > 1 { 101 | // this unwrap is safe because we only ever continue the loop 102 | // if we have set err_msg 103 | Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) 104 | } else { 105 | None 106 | }; 107 | let password = rbw::pinentry::getpin( 108 | &config_pinentry().await?, 109 | "Master Password", 110 | &format!("Log in to {host}"), 111 | err.as_deref(), 112 | environment, 113 | true, 114 | ) 115 | .await 116 | .context("failed to read password from pinentry")?; 117 | match rbw::actions::login(&email, password.clone(), None, None) 118 | .await 119 | { 120 | Ok(( 121 | access_token, 122 | refresh_token, 123 | kdf, 124 | iterations, 125 | memory, 126 | parallelism, 127 | protected_key, 128 | )) => { 129 | login_success( 130 | state.clone(), 131 | access_token, 132 | refresh_token, 133 | kdf, 134 | iterations, 135 | memory, 136 | parallelism, 137 | protected_key, 138 | password, 139 | db, 140 | email, 141 | ) 142 | .await?; 143 | break 'attempts; 144 | } 145 | Err(rbw::error::Error::TwoFactorRequired { providers }) => { 146 | let supported_types = vec![ 147 | rbw::api::TwoFactorProviderType::Authenticator, 148 | rbw::api::TwoFactorProviderType::Yubikey, 149 | rbw::api::TwoFactorProviderType::Email, 150 | ]; 151 | 152 | for provider in supported_types { 153 | if providers.contains(&provider) { 154 | let ( 155 | access_token, 156 | refresh_token, 157 | kdf, 158 | iterations, 159 | memory, 160 | parallelism, 161 | protected_key, 162 | ) = two_factor( 163 | environment, 164 | &email, 165 | password.clone(), 166 | provider, 167 | ) 168 | .await?; 169 | login_success( 170 | state.clone(), 171 | access_token, 172 | refresh_token, 173 | kdf, 174 | iterations, 175 | memory, 176 | parallelism, 177 | protected_key, 178 | password, 179 | db, 180 | email, 181 | ) 182 | .await?; 183 | break 'attempts; 184 | } 185 | } 186 | return Err(anyhow::anyhow!( 187 | "unsupported two factor methods: {providers:?}" 188 | )); 189 | } 190 | Err(rbw::error::Error::IncorrectPassword { message }) => { 191 | if i == 3 { 192 | return Err(rbw::error::Error::IncorrectPassword { 193 | message, 194 | }) 195 | .context("failed to log in to bitwarden instance"); 196 | } 197 | err_msg = Some(message); 198 | } 199 | Err(e) => { 200 | return Err(e) 201 | .context("failed to log in to bitwarden instance") 202 | } 203 | } 204 | } 205 | } 206 | 207 | respond_ack(sock).await?; 208 | 209 | Ok(()) 210 | } 211 | 212 | async fn two_factor( 213 | environment: &rbw::protocol::Environment, 214 | email: &str, 215 | password: rbw::locked::Password, 216 | provider: rbw::api::TwoFactorProviderType, 217 | ) -> anyhow::Result<( 218 | String, 219 | String, 220 | rbw::api::KdfType, 221 | u32, 222 | Option, 223 | Option, 224 | String, 225 | )> { 226 | let mut err_msg = None; 227 | for i in 1_u8..=3 { 228 | let err = if i > 1 { 229 | // this unwrap is safe because we only ever continue the loop if 230 | // we have set err_msg 231 | Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) 232 | } else { 233 | None 234 | }; 235 | let code = rbw::pinentry::getpin( 236 | &config_pinentry().await?, 237 | provider.header(), 238 | provider.message(), 239 | err.as_deref(), 240 | environment, 241 | provider.grab(), 242 | ) 243 | .await 244 | .context("failed to read code from pinentry")?; 245 | let code = std::str::from_utf8(code.password()) 246 | .context("code was not valid utf8")?; 247 | match rbw::actions::login( 248 | email, 249 | password.clone(), 250 | Some(code), 251 | Some(provider), 252 | ) 253 | .await 254 | { 255 | Ok(( 256 | access_token, 257 | refresh_token, 258 | kdf, 259 | iterations, 260 | memory, 261 | parallelism, 262 | protected_key, 263 | )) => { 264 | return Ok(( 265 | access_token, 266 | refresh_token, 267 | kdf, 268 | iterations, 269 | memory, 270 | parallelism, 271 | protected_key, 272 | )) 273 | } 274 | Err(rbw::error::Error::IncorrectPassword { message }) => { 275 | if i == 3 { 276 | return Err(rbw::error::Error::IncorrectPassword { 277 | message, 278 | }) 279 | .context("failed to log in to bitwarden instance"); 280 | } 281 | err_msg = Some(message); 282 | } 283 | // can get this if the user passes an empty string 284 | Err(rbw::error::Error::TwoFactorRequired { .. }) => { 285 | let message = "TOTP code is not a number".to_string(); 286 | if i == 3 { 287 | return Err(rbw::error::Error::IncorrectPassword { 288 | message, 289 | }) 290 | .context("failed to log in to bitwarden instance"); 291 | } 292 | err_msg = Some(message); 293 | } 294 | Err(e) => { 295 | return Err(e) 296 | .context("failed to log in to bitwarden instance") 297 | } 298 | } 299 | } 300 | 301 | unreachable!() 302 | } 303 | 304 | async fn login_success( 305 | state: std::sync::Arc>, 306 | access_token: String, 307 | refresh_token: String, 308 | kdf: rbw::api::KdfType, 309 | iterations: u32, 310 | memory: Option, 311 | parallelism: Option, 312 | protected_key: String, 313 | password: rbw::locked::Password, 314 | mut db: rbw::db::Db, 315 | email: String, 316 | ) -> anyhow::Result<()> { 317 | db.access_token = Some(access_token.to_string()); 318 | db.refresh_token = Some(refresh_token.to_string()); 319 | db.kdf = Some(kdf); 320 | db.iterations = Some(iterations); 321 | db.memory = memory; 322 | db.parallelism = parallelism; 323 | db.protected_key = Some(protected_key.to_string()); 324 | save_db(&db).await?; 325 | 326 | sync(None, state.clone()).await?; 327 | let db = load_db().await?; 328 | 329 | let Some(protected_private_key) = db.protected_private_key else { 330 | return Err(anyhow::anyhow!( 331 | "failed to find protected private key in db" 332 | )); 333 | }; 334 | 335 | let res = rbw::actions::unlock( 336 | &email, 337 | &password, 338 | kdf, 339 | iterations, 340 | memory, 341 | parallelism, 342 | &protected_key, 343 | &protected_private_key, 344 | &db.protected_org_keys, 345 | ); 346 | 347 | match res { 348 | Ok((keys, org_keys)) => { 349 | let mut state = state.lock().await; 350 | state.priv_key = Some(keys); 351 | state.org_keys = Some(org_keys); 352 | } 353 | Err(e) => return Err(e).context("failed to unlock database"), 354 | } 355 | 356 | Ok(()) 357 | } 358 | 359 | pub async fn unlock( 360 | sock: &mut crate::sock::Sock, 361 | state: std::sync::Arc>, 362 | environment: &rbw::protocol::Environment, 363 | ) -> anyhow::Result<()> { 364 | if state.lock().await.needs_unlock() { 365 | let db = load_db().await?; 366 | 367 | let Some(kdf) = db.kdf else { 368 | return Err(anyhow::anyhow!("failed to find kdf type in db")); 369 | }; 370 | 371 | let Some(iterations) = db.iterations else { 372 | return Err(anyhow::anyhow!( 373 | "failed to find number of iterations in db" 374 | )); 375 | }; 376 | 377 | let memory = db.memory; 378 | let parallelism = db.parallelism; 379 | 380 | let Some(protected_key) = db.protected_key else { 381 | return Err(anyhow::anyhow!( 382 | "failed to find protected key in db" 383 | )); 384 | }; 385 | let Some(protected_private_key) = db.protected_private_key else { 386 | return Err(anyhow::anyhow!( 387 | "failed to find protected private key in db" 388 | )); 389 | }; 390 | 391 | let email = config_email().await?; 392 | 393 | let mut err_msg = None; 394 | for i in 1_u8..=3 { 395 | let err = if i > 1 { 396 | // this unwrap is safe because we only ever continue the loop 397 | // if we have set err_msg 398 | Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) 399 | } else { 400 | None 401 | }; 402 | let password = rbw::pinentry::getpin( 403 | &config_pinentry().await?, 404 | "Master Password", 405 | &format!( 406 | "Unlock the local database for '{}'", 407 | rbw::dirs::profile() 408 | ), 409 | err.as_deref(), 410 | environment, 411 | true, 412 | ) 413 | .await 414 | .context("failed to read password from pinentry")?; 415 | match rbw::actions::unlock( 416 | &email, 417 | &password, 418 | kdf, 419 | iterations, 420 | memory, 421 | parallelism, 422 | &protected_key, 423 | &protected_private_key, 424 | &db.protected_org_keys, 425 | ) { 426 | Ok((keys, org_keys)) => { 427 | unlock_success(state, keys, org_keys).await?; 428 | break; 429 | } 430 | Err(rbw::error::Error::IncorrectPassword { message }) => { 431 | if i == 3 { 432 | return Err(rbw::error::Error::IncorrectPassword { 433 | message, 434 | }) 435 | .context("failed to unlock database"); 436 | } 437 | err_msg = Some(message); 438 | } 439 | Err(e) => return Err(e).context("failed to unlock database"), 440 | } 441 | } 442 | } 443 | 444 | respond_ack(sock).await?; 445 | 446 | Ok(()) 447 | } 448 | 449 | async fn unlock_success( 450 | state: std::sync::Arc>, 451 | keys: rbw::locked::Keys, 452 | org_keys: std::collections::HashMap, 453 | ) -> anyhow::Result<()> { 454 | let mut state = state.lock().await; 455 | state.priv_key = Some(keys); 456 | state.org_keys = Some(org_keys); 457 | Ok(()) 458 | } 459 | 460 | pub async fn lock( 461 | sock: &mut crate::sock::Sock, 462 | state: std::sync::Arc>, 463 | ) -> anyhow::Result<()> { 464 | state.lock().await.clear(); 465 | 466 | respond_ack(sock).await?; 467 | 468 | Ok(()) 469 | } 470 | 471 | pub async fn check_lock( 472 | sock: &mut crate::sock::Sock, 473 | state: std::sync::Arc>, 474 | ) -> anyhow::Result<()> { 475 | if state.lock().await.needs_unlock() { 476 | return Err(anyhow::anyhow!("agent is locked")); 477 | } 478 | 479 | respond_ack(sock).await?; 480 | 481 | Ok(()) 482 | } 483 | 484 | pub async fn sync( 485 | sock: Option<&mut crate::sock::Sock>, 486 | state: std::sync::Arc>, 487 | ) -> anyhow::Result<()> { 488 | let mut db = load_db().await?; 489 | 490 | let access_token = if let Some(access_token) = &db.access_token { 491 | access_token.clone() 492 | } else { 493 | return Err(anyhow::anyhow!("failed to find access token in db")); 494 | }; 495 | let refresh_token = if let Some(refresh_token) = &db.refresh_token { 496 | refresh_token.clone() 497 | } else { 498 | return Err(anyhow::anyhow!("failed to find refresh token in db")); 499 | }; 500 | let ( 501 | access_token, 502 | (protected_key, protected_private_key, protected_org_keys, entries), 503 | ) = rbw::actions::sync(&access_token, &refresh_token) 504 | .await 505 | .context("failed to sync database from server")?; 506 | if let Some(access_token) = access_token { 507 | db.access_token = Some(access_token); 508 | } 509 | db.protected_key = Some(protected_key); 510 | db.protected_private_key = Some(protected_private_key); 511 | db.protected_org_keys = protected_org_keys; 512 | db.entries = entries; 513 | save_db(&db).await?; 514 | 515 | if let Err(e) = subscribe_to_notifications(state.clone()).await { 516 | eprintln!("failed to subscribe to notifications: {e}"); 517 | } 518 | 519 | if let Some(sock) = sock { 520 | respond_ack(sock).await?; 521 | } 522 | 523 | Ok(()) 524 | } 525 | 526 | pub async fn decrypt( 527 | sock: &mut crate::sock::Sock, 528 | state: std::sync::Arc>, 529 | cipherstring: &str, 530 | entry_key: Option<&str>, 531 | org_id: Option<&str>, 532 | ) -> anyhow::Result<()> { 533 | let state = state.lock().await; 534 | let Some(keys) = state.key(org_id) else { 535 | return Err(anyhow::anyhow!( 536 | "failed to find decryption keys in in-memory state" 537 | )); 538 | }; 539 | let entry_key = if let Some(entry_key) = entry_key { 540 | let key_cipherstring = 541 | rbw::cipherstring::CipherString::new(entry_key) 542 | .context("failed to parse individual item encryption key")?; 543 | Some(rbw::locked::Keys::new( 544 | key_cipherstring.decrypt_locked_symmetric(keys).context( 545 | "failed to decrypt individual item encryption key", 546 | )?, 547 | )) 548 | } else { 549 | None 550 | }; 551 | let cipherstring = rbw::cipherstring::CipherString::new(cipherstring) 552 | .context("failed to parse encrypted secret")?; 553 | let plaintext = String::from_utf8( 554 | cipherstring 555 | .decrypt_symmetric(keys, entry_key.as_ref()) 556 | .context("failed to decrypt encrypted secret")?, 557 | ) 558 | .context("failed to parse decrypted secret")?; 559 | 560 | respond_decrypt(sock, plaintext).await?; 561 | 562 | Ok(()) 563 | } 564 | 565 | pub async fn encrypt( 566 | sock: &mut crate::sock::Sock, 567 | state: std::sync::Arc>, 568 | plaintext: &str, 569 | org_id: Option<&str>, 570 | ) -> anyhow::Result<()> { 571 | let state = state.lock().await; 572 | let Some(keys) = state.key(org_id) else { 573 | return Err(anyhow::anyhow!( 574 | "failed to find encryption keys in in-memory state" 575 | )); 576 | }; 577 | let cipherstring = rbw::cipherstring::CipherString::encrypt_symmetric( 578 | keys, 579 | plaintext.as_bytes(), 580 | ) 581 | .context("failed to encrypt plaintext secret")?; 582 | 583 | respond_encrypt(sock, cipherstring.to_string()).await?; 584 | 585 | Ok(()) 586 | } 587 | 588 | #[cfg(feature = "clipboard")] 589 | pub async fn clipboard_store( 590 | sock: &mut crate::sock::Sock, 591 | state: std::sync::Arc>, 592 | text: &str, 593 | ) -> anyhow::Result<()> { 594 | let mut state = state.lock().await; 595 | if let Some(clipboard) = &mut state.clipboard { 596 | clipboard.set_text(text).map_err(|e| { 597 | anyhow::anyhow!("couldn't store value to clipboard: {e}") 598 | })?; 599 | } 600 | 601 | respond_ack(sock).await?; 602 | 603 | Ok(()) 604 | } 605 | 606 | #[cfg(not(feature = "clipboard"))] 607 | pub async fn clipboard_store( 608 | sock: &mut crate::sock::Sock, 609 | _state: std::sync::Arc>, 610 | _text: &str, 611 | ) -> anyhow::Result<()> { 612 | sock.send(&rbw::protocol::Response::Error { 613 | error: "clipboard not supported".to_string(), 614 | }) 615 | .await?; 616 | 617 | Ok(()) 618 | } 619 | 620 | pub async fn version(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { 621 | sock.send(&rbw::protocol::Response::Version { 622 | version: rbw::protocol::version(), 623 | }) 624 | .await?; 625 | 626 | Ok(()) 627 | } 628 | 629 | async fn respond_ack(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { 630 | sock.send(&rbw::protocol::Response::Ack).await?; 631 | 632 | Ok(()) 633 | } 634 | 635 | async fn respond_decrypt( 636 | sock: &mut crate::sock::Sock, 637 | plaintext: String, 638 | ) -> anyhow::Result<()> { 639 | sock.send(&rbw::protocol::Response::Decrypt { plaintext }) 640 | .await?; 641 | 642 | Ok(()) 643 | } 644 | 645 | async fn respond_encrypt( 646 | sock: &mut crate::sock::Sock, 647 | cipherstring: String, 648 | ) -> anyhow::Result<()> { 649 | sock.send(&rbw::protocol::Response::Encrypt { cipherstring }) 650 | .await?; 651 | 652 | Ok(()) 653 | } 654 | 655 | async fn config_email() -> anyhow::Result { 656 | let config = rbw::config::Config::load_async().await?; 657 | config.email.map_or_else( 658 | || Err(anyhow::anyhow!("failed to find email address in config")), 659 | Ok, 660 | ) 661 | } 662 | 663 | async fn load_db() -> anyhow::Result { 664 | let config = rbw::config::Config::load_async().await?; 665 | if let Some(email) = &config.email { 666 | rbw::db::Db::load_async(&config.server_name(), email) 667 | .await 668 | .map_err(anyhow::Error::new) 669 | } else { 670 | Err(anyhow::anyhow!("failed to find email address in config")) 671 | } 672 | } 673 | 674 | async fn save_db(db: &rbw::db::Db) -> anyhow::Result<()> { 675 | let config = rbw::config::Config::load_async().await?; 676 | if let Some(email) = &config.email { 677 | db.save_async(&config.server_name(), email) 678 | .await 679 | .map_err(anyhow::Error::new) 680 | } else { 681 | Err(anyhow::anyhow!("failed to find email address in config")) 682 | } 683 | } 684 | 685 | async fn config_base_url() -> anyhow::Result { 686 | let config = rbw::config::Config::load_async().await?; 687 | Ok(config.base_url()) 688 | } 689 | 690 | async fn config_pinentry() -> anyhow::Result { 691 | let config = rbw::config::Config::load_async().await?; 692 | Ok(config.pinentry) 693 | } 694 | 695 | pub async fn subscribe_to_notifications( 696 | state: std::sync::Arc>, 697 | ) -> anyhow::Result<()> { 698 | if state.lock().await.notifications_handler.is_connected() { 699 | return Ok(()); 700 | } 701 | 702 | let config = rbw::config::Config::load_async() 703 | .await 704 | .context("Config is missing")?; 705 | let email = config.email.clone().context("Config is missing email")?; 706 | let db = rbw::db::Db::load_async(config.server_name().as_str(), &email) 707 | .await?; 708 | let access_token = 709 | db.access_token.context("Error getting access token")?; 710 | 711 | let websocket_url = format!( 712 | "{}/hub?access_token={}", 713 | config.notifications_url(), 714 | access_token 715 | ) 716 | .replace("https://", "wss://"); 717 | 718 | let mut state = state.lock().await; 719 | state 720 | .notifications_handler 721 | .connect(websocket_url) 722 | .await 723 | .err() 724 | .map_or_else(|| Ok(()), |err| Err(anyhow::anyhow!(err.to_string()))) 725 | } 726 | -------------------------------------------------------------------------------- /src/bin/rbw-agent/agent.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context as _; 2 | use futures_util::StreamExt as _; 3 | 4 | pub struct State { 5 | pub priv_key: Option, 6 | pub org_keys: 7 | Option>, 8 | pub timeout: crate::timeout::Timeout, 9 | pub timeout_duration: std::time::Duration, 10 | pub sync_timeout: crate::timeout::Timeout, 11 | pub sync_timeout_duration: std::time::Duration, 12 | pub notifications_handler: crate::notifications::Handler, 13 | #[cfg(feature = "clipboard")] 14 | pub clipboard: Option, 15 | } 16 | 17 | impl State { 18 | pub fn key(&self, org_id: Option<&str>) -> Option<&rbw::locked::Keys> { 19 | org_id.map_or(self.priv_key.as_ref(), |id| { 20 | self.org_keys.as_ref().and_then(|h| h.get(id)) 21 | }) 22 | } 23 | 24 | pub fn needs_unlock(&self) -> bool { 25 | self.priv_key.is_none() || self.org_keys.is_none() 26 | } 27 | 28 | pub fn set_timeout(&self) { 29 | self.timeout.set(self.timeout_duration); 30 | } 31 | 32 | pub fn clear(&mut self) { 33 | self.priv_key = None; 34 | self.org_keys = None; 35 | self.timeout.clear(); 36 | } 37 | 38 | pub fn set_sync_timeout(&self) { 39 | self.sync_timeout.set(self.sync_timeout_duration); 40 | } 41 | } 42 | 43 | pub struct Agent { 44 | timer_r: tokio::sync::mpsc::UnboundedReceiver<()>, 45 | sync_timer_r: tokio::sync::mpsc::UnboundedReceiver<()>, 46 | state: std::sync::Arc>, 47 | } 48 | 49 | impl Agent { 50 | pub fn new() -> anyhow::Result { 51 | let config = rbw::config::Config::load()?; 52 | let timeout_duration = 53 | std::time::Duration::from_secs(config.lock_timeout); 54 | let sync_timeout_duration = 55 | std::time::Duration::from_secs(config.sync_interval); 56 | let (timeout, timer_r) = crate::timeout::Timeout::new(); 57 | let (sync_timeout, sync_timer_r) = crate::timeout::Timeout::new(); 58 | if sync_timeout_duration > std::time::Duration::ZERO { 59 | sync_timeout.set(sync_timeout_duration); 60 | } 61 | let notifications_handler = crate::notifications::Handler::new(); 62 | Ok(Self { 63 | timer_r, 64 | sync_timer_r, 65 | state: std::sync::Arc::new(tokio::sync::Mutex::new(State { 66 | priv_key: None, 67 | org_keys: None, 68 | timeout, 69 | timeout_duration, 70 | sync_timeout, 71 | sync_timeout_duration, 72 | notifications_handler, 73 | #[cfg(feature = "clipboard")] 74 | clipboard: arboard::Clipboard::new() 75 | .inspect_err(|e| { 76 | log::warn!("couldn't create clipboard context: {e}"); 77 | }) 78 | .ok(), 79 | })), 80 | }) 81 | } 82 | 83 | pub async fn run( 84 | self, 85 | listener: tokio::net::UnixListener, 86 | ) -> anyhow::Result<()> { 87 | pub enum Event { 88 | Request(std::io::Result), 89 | Timeout(()), 90 | Sync(()), 91 | } 92 | 93 | let notifications = self 94 | .state 95 | .lock() 96 | .await 97 | .notifications_handler 98 | .get_channel() 99 | .await; 100 | let notifications = 101 | tokio_stream::wrappers::UnboundedReceiverStream::new( 102 | notifications, 103 | ) 104 | .map(|message| match message { 105 | crate::notifications::Message::Logout => Event::Timeout(()), 106 | crate::notifications::Message::Sync => Event::Sync(()), 107 | }) 108 | .boxed(); 109 | 110 | let mut stream = futures_util::stream::select_all([ 111 | tokio_stream::wrappers::UnixListenerStream::new(listener) 112 | .map(Event::Request) 113 | .boxed(), 114 | tokio_stream::wrappers::UnboundedReceiverStream::new( 115 | self.timer_r, 116 | ) 117 | .map(Event::Timeout) 118 | .boxed(), 119 | tokio_stream::wrappers::UnboundedReceiverStream::new( 120 | self.sync_timer_r, 121 | ) 122 | .map(Event::Sync) 123 | .boxed(), 124 | notifications, 125 | ]); 126 | while let Some(event) = stream.next().await { 127 | match event { 128 | Event::Request(res) => { 129 | let mut sock = crate::sock::Sock::new( 130 | res.context("failed to accept incoming connection")?, 131 | ); 132 | let state = self.state.clone(); 133 | tokio::spawn(async move { 134 | let res = 135 | handle_request(&mut sock, state.clone()).await; 136 | if let Err(e) = res { 137 | // unwrap is the only option here 138 | sock.send(&rbw::protocol::Response::Error { 139 | error: format!("{e:#}"), 140 | }) 141 | .await 142 | .unwrap(); 143 | } 144 | }); 145 | } 146 | Event::Timeout(()) => { 147 | self.state.lock().await.clear(); 148 | } 149 | Event::Sync(()) => { 150 | let state = self.state.clone(); 151 | tokio::spawn(async move { 152 | // this could fail if we aren't logged in, but we 153 | // don't care about that 154 | if let Err(e) = 155 | crate::actions::sync(None, state.clone()).await 156 | { 157 | eprintln!("failed to sync: {e:#}"); 158 | } 159 | }); 160 | self.state.lock().await.set_sync_timeout(); 161 | } 162 | } 163 | } 164 | Ok(()) 165 | } 166 | } 167 | 168 | async fn handle_request( 169 | sock: &mut crate::sock::Sock, 170 | state: std::sync::Arc>, 171 | ) -> anyhow::Result<()> { 172 | let req = sock.recv().await?; 173 | let req = match req { 174 | Ok(msg) => msg, 175 | Err(error) => { 176 | sock.send(&rbw::protocol::Response::Error { error }).await?; 177 | return Ok(()); 178 | } 179 | }; 180 | let set_timeout = match &req.action { 181 | rbw::protocol::Action::Register => { 182 | crate::actions::register(sock, &req.environment()).await?; 183 | true 184 | } 185 | rbw::protocol::Action::Login => { 186 | crate::actions::login(sock, state.clone(), &req.environment()) 187 | .await?; 188 | true 189 | } 190 | rbw::protocol::Action::Unlock => { 191 | crate::actions::unlock(sock, state.clone(), &req.environment()) 192 | .await?; 193 | true 194 | } 195 | rbw::protocol::Action::CheckLock => { 196 | crate::actions::check_lock(sock, state.clone()).await?; 197 | false 198 | } 199 | rbw::protocol::Action::Lock => { 200 | crate::actions::lock(sock, state.clone()).await?; 201 | false 202 | } 203 | rbw::protocol::Action::Sync => { 204 | crate::actions::sync(Some(sock), state.clone()).await?; 205 | false 206 | } 207 | rbw::protocol::Action::Decrypt { 208 | cipherstring, 209 | entry_key, 210 | org_id, 211 | } => { 212 | crate::actions::decrypt( 213 | sock, 214 | state.clone(), 215 | cipherstring, 216 | entry_key.as_deref(), 217 | org_id.as_deref(), 218 | ) 219 | .await?; 220 | true 221 | } 222 | rbw::protocol::Action::Encrypt { plaintext, org_id } => { 223 | crate::actions::encrypt( 224 | sock, 225 | state.clone(), 226 | plaintext, 227 | org_id.as_deref(), 228 | ) 229 | .await?; 230 | true 231 | } 232 | rbw::protocol::Action::ClipboardStore { text } => { 233 | crate::actions::clipboard_store(sock, state.clone(), text) 234 | .await?; 235 | true 236 | } 237 | rbw::protocol::Action::Quit => std::process::exit(0), 238 | rbw::protocol::Action::Version => { 239 | crate::actions::version(sock).await?; 240 | false 241 | } 242 | }; 243 | 244 | if set_timeout { 245 | state.lock().await.set_timeout(); 246 | } 247 | 248 | Ok(()) 249 | } 250 | -------------------------------------------------------------------------------- /src/bin/rbw-agent/daemon.rs: -------------------------------------------------------------------------------- 1 | pub struct StartupAck { 2 | writer: std::os::unix::io::OwnedFd, 3 | } 4 | 5 | impl StartupAck { 6 | pub fn ack(self) -> anyhow::Result<()> { 7 | rustix::io::write(&self.writer, &[0])?; 8 | Ok(()) 9 | } 10 | } 11 | 12 | pub fn daemonize() -> anyhow::Result { 13 | let stdout = std::fs::OpenOptions::new() 14 | .append(true) 15 | .create(true) 16 | .open(rbw::dirs::agent_stdout_file())?; 17 | let stderr = std::fs::OpenOptions::new() 18 | .append(true) 19 | .create(true) 20 | .open(rbw::dirs::agent_stderr_file())?; 21 | 22 | let (r, w) = rustix::pipe::pipe()?; 23 | let daemonize = daemonize::Daemonize::new() 24 | .pid_file(rbw::dirs::pid_file()) 25 | .stdout(stdout) 26 | .stderr(stderr); 27 | let res = match daemonize.execute() { 28 | daemonize::Outcome::Parent(_) => { 29 | drop(w); 30 | let mut buf = [0; 1]; 31 | // unwraps are necessary because not really a good way to handle 32 | // errors here otherwise 33 | rustix::io::read(&r, &mut buf).unwrap(); 34 | drop(r); 35 | std::process::exit(0); 36 | } 37 | daemonize::Outcome::Child(res) => res, 38 | }; 39 | 40 | drop(r); 41 | 42 | match res { 43 | Ok(_) => (), 44 | Err(e) => { 45 | // XXX super gross, but daemonize removed the ability to match 46 | // on specific error types for some reason? 47 | if e.to_string().contains("unable to lock pid file") { 48 | // this means that there is already an agent running, so 49 | // return a special exit code to allow the cli to detect 50 | // this case and not error out 51 | std::process::exit(23); 52 | } else { 53 | panic!("failed to daemonize: {e}"); 54 | } 55 | } 56 | } 57 | 58 | Ok(StartupAck { writer: w }) 59 | } 60 | -------------------------------------------------------------------------------- /src/bin/rbw-agent/debugger.rs: -------------------------------------------------------------------------------- 1 | // Prevent other user processes from attaching to the rbw agent and dumping 2 | // memory This is not perfect protection, but closes a door. Unfortunately, 3 | // prctl only works on Linux. 4 | #[cfg(target_os = "linux")] 5 | pub fn disable_tracing() -> anyhow::Result<()> { 6 | // https://github.com/torvalds/linux/blob/v5.11/include/uapi/linux/prctl.h#L14 7 | const PR_SET_DUMPABLE: i32 = 4; 8 | 9 | // safe because it's just a raw call to prctl, and the arguments are 10 | // correct 11 | let ret = unsafe { libc::prctl(PR_SET_DUMPABLE, 0) }; 12 | if ret == 0 { 13 | Ok(()) 14 | } else { 15 | let e = std::io::Error::last_os_error(); 16 | Err(anyhow::anyhow!("failed to disable PTRACE_ATTACH, agent memory may be dumpable by other processes: {}", e)) 17 | } 18 | } 19 | 20 | #[cfg(not(target_os = "linux"))] 21 | pub fn disable_tracing() -> anyhow::Result<()> { 22 | Err(anyhow::anyhow!("failed to disable PTRACE_ATTACH, agent memory may be dumpable by other processes: unimplemented on this platform")) 23 | } 24 | -------------------------------------------------------------------------------- /src/bin/rbw-agent/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context as _; 2 | 3 | mod actions; 4 | mod agent; 5 | mod daemon; 6 | mod debugger; 7 | mod notifications; 8 | mod sock; 9 | mod timeout; 10 | 11 | async fn tokio_main( 12 | startup_ack: Option, 13 | ) -> anyhow::Result<()> { 14 | let listener = crate::sock::listen()?; 15 | 16 | if let Some(startup_ack) = startup_ack { 17 | startup_ack.ack()?; 18 | } 19 | 20 | let agent = crate::agent::Agent::new()?; 21 | agent.run(listener).await?; 22 | 23 | Ok(()) 24 | } 25 | 26 | fn real_main() -> anyhow::Result<()> { 27 | env_logger::Builder::from_env( 28 | env_logger::Env::default().default_filter_or("info"), 29 | ) 30 | .init(); 31 | 32 | let no_daemonize = std::env::args() 33 | .nth(1) 34 | .is_some_and(|arg| arg == "--no-daemonize"); 35 | 36 | rbw::dirs::make_all()?; 37 | 38 | let startup_ack = if no_daemonize { 39 | None 40 | } else { 41 | Some(daemon::daemonize().context("failed to daemonize")?) 42 | }; 43 | 44 | if let Err(e) = debugger::disable_tracing() { 45 | log::warn!("{e}"); 46 | } 47 | 48 | let (w, r) = std::sync::mpsc::channel(); 49 | // can't use tokio::main because we need to daemonize before starting the 50 | // tokio runloop, or else things break 51 | // unwrap is fine here because there's no good reason that this should 52 | // ever fail 53 | tokio::runtime::Runtime::new().unwrap().block_on(async { 54 | if let Err(e) = tokio_main(startup_ack).await { 55 | // this unwrap is fine because it's the only real option here 56 | w.send(e).unwrap(); 57 | } 58 | }); 59 | 60 | if let Ok(e) = r.recv() { 61 | return Err(e); 62 | } 63 | 64 | Ok(()) 65 | } 66 | 67 | fn main() { 68 | let res = real_main(); 69 | 70 | if let Err(e) = res { 71 | // XXX log file? 72 | eprintln!("{e:#}"); 73 | std::process::exit(1); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/bin/rbw-agent/notifications.rs: -------------------------------------------------------------------------------- 1 | use futures_util::{SinkExt as _, StreamExt as _}; 2 | 3 | #[derive(Clone, Copy, Debug)] 4 | pub enum Message { 5 | Sync, 6 | Logout, 7 | } 8 | 9 | pub struct Handler { 10 | write: Option< 11 | futures::stream::SplitSink< 12 | tokio_tungstenite::WebSocketStream< 13 | tokio_tungstenite::MaybeTlsStream, 14 | >, 15 | tokio_tungstenite::tungstenite::Message, 16 | >, 17 | >, 18 | read_handle: Option>, 19 | sending_channels: std::sync::Arc< 20 | tokio::sync::RwLock>>, 21 | >, 22 | } 23 | 24 | impl Handler { 25 | pub fn new() -> Self { 26 | Self { 27 | write: None, 28 | read_handle: None, 29 | sending_channels: std::sync::Arc::new(tokio::sync::RwLock::new( 30 | Vec::new(), 31 | )), 32 | } 33 | } 34 | 35 | pub async fn connect( 36 | &mut self, 37 | url: String, 38 | ) -> Result<(), Box> { 39 | if self.is_connected() { 40 | self.disconnect().await?; 41 | } 42 | 43 | let (write, read_handle) = 44 | subscribe_to_notifications(url, self.sending_channels.clone()) 45 | .await?; 46 | 47 | self.write = Some(write); 48 | self.read_handle = Some(read_handle); 49 | Ok(()) 50 | } 51 | 52 | pub fn is_connected(&self) -> bool { 53 | self.write.is_some() 54 | && self.read_handle.is_some() 55 | && !self.read_handle.as_ref().unwrap().is_finished() 56 | } 57 | 58 | pub async fn disconnect( 59 | &mut self, 60 | ) -> Result<(), Box> { 61 | self.sending_channels.write().await.clear(); 62 | if let Some(mut write) = self.write.take() { 63 | write 64 | .send(tokio_tungstenite::tungstenite::Message::Close(None)) 65 | .await?; 66 | write.close().await?; 67 | self.read_handle.take().unwrap().await?; 68 | } 69 | self.write = None; 70 | self.read_handle = None; 71 | Ok(()) 72 | } 73 | 74 | pub async fn get_channel( 75 | &self, 76 | ) -> tokio::sync::mpsc::UnboundedReceiver { 77 | let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); 78 | self.sending_channels.write().await.push(tx); 79 | rx 80 | } 81 | } 82 | 83 | async fn subscribe_to_notifications( 84 | url: String, 85 | sending_channels: std::sync::Arc< 86 | tokio::sync::RwLock>>, 87 | >, 88 | ) -> Result< 89 | ( 90 | futures_util::stream::SplitSink< 91 | tokio_tungstenite::WebSocketStream< 92 | tokio_tungstenite::MaybeTlsStream, 93 | >, 94 | tokio_tungstenite::tungstenite::Message, 95 | >, 96 | tokio::task::JoinHandle<()>, 97 | ), 98 | Box, 99 | > { 100 | let url = url::Url::parse(url.as_str())?; 101 | let (ws_stream, _response) = 102 | tokio_tungstenite::connect_async(url).await?; 103 | let (mut write, read) = ws_stream.split(); 104 | 105 | write 106 | .send(tokio_tungstenite::tungstenite::Message::Text( 107 | "{\"protocol\":\"messagepack\",\"version\":1}\x1e".to_string(), 108 | )) 109 | .await 110 | .unwrap(); 111 | 112 | let read_future = async move { 113 | let sending_channels = &sending_channels; 114 | read.for_each(|message| async move { 115 | match message { 116 | Ok(message) => { 117 | if let Some(message) = parse_message(message) { 118 | let sending_channels = sending_channels.read().await; 119 | let sending_channels = sending_channels.as_slice(); 120 | for channel in sending_channels { 121 | channel.send(message).unwrap(); 122 | } 123 | } 124 | } 125 | Err(e) => { 126 | eprintln!("websocket error: {e:?}"); 127 | } 128 | } 129 | }) 130 | .await; 131 | }; 132 | 133 | Ok((write, tokio::spawn(read_future))) 134 | } 135 | 136 | fn parse_message( 137 | message: tokio_tungstenite::tungstenite::Message, 138 | ) -> Option { 139 | let tokio_tungstenite::tungstenite::Message::Binary(data) = message 140 | else { 141 | return None; 142 | }; 143 | 144 | // the first few bytes with the 0x80 bit set, plus one byte terminating the length contain the length of the message 145 | let len_buffer_length = data.iter().position(|&x| (x & 0x80) == 0)? + 1; 146 | 147 | let unpacked_messagepack = 148 | rmpv::decode::read_value(&mut &data[len_buffer_length..]).ok()?; 149 | 150 | let unpacked_message = unpacked_messagepack.as_array()?; 151 | let message_type = unpacked_message.first()?.as_u64()?; 152 | // invocation 153 | if message_type != 1 { 154 | return None; 155 | } 156 | let target = unpacked_message.get(3)?.as_str()?; 157 | if target != "ReceiveMessage" { 158 | return None; 159 | } 160 | 161 | let args = unpacked_message.get(4)?.as_array()?; 162 | let map = args.first()?.as_map()?; 163 | for (k, v) in map { 164 | if k.as_str()? == "Type" { 165 | let ty = v.as_i64()?; 166 | return match ty { 167 | 11 => Some(Message::Logout), 168 | _ => Some(Message::Sync), 169 | }; 170 | } 171 | } 172 | 173 | None 174 | } 175 | -------------------------------------------------------------------------------- /src/bin/rbw-agent/sock.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context as _; 2 | use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _}; 3 | 4 | pub struct Sock(tokio::net::UnixStream); 5 | 6 | impl Sock { 7 | pub fn new(s: tokio::net::UnixStream) -> Self { 8 | Self(s) 9 | } 10 | 11 | pub async fn send( 12 | &mut self, 13 | res: &rbw::protocol::Response, 14 | ) -> anyhow::Result<()> { 15 | if let rbw::protocol::Response::Error { error } = res { 16 | log::warn!("{error}"); 17 | } 18 | 19 | let Self(sock) = self; 20 | sock.write_all( 21 | serde_json::to_string(res) 22 | .context("failed to serialize message")? 23 | .as_bytes(), 24 | ) 25 | .await 26 | .context("failed to write message to socket")?; 27 | sock.write_all(b"\n") 28 | .await 29 | .context("failed to write message to socket")?; 30 | Ok(()) 31 | } 32 | 33 | pub async fn recv( 34 | &mut self, 35 | ) -> anyhow::Result> 36 | { 37 | let Self(sock) = self; 38 | let mut buf = tokio::io::BufStream::new(sock); 39 | let mut line = String::new(); 40 | buf.read_line(&mut line) 41 | .await 42 | .context("failed to read message from socket")?; 43 | Ok(serde_json::from_str(&line) 44 | .map_err(|e| format!("failed to parse message '{line}': {e}"))) 45 | } 46 | } 47 | 48 | pub fn listen() -> anyhow::Result { 49 | let path = rbw::dirs::socket_file(); 50 | // if the socket already doesn't exist, that's fine 51 | let _ = std::fs::remove_file(&path); 52 | let sock = tokio::net::UnixListener::bind(&path) 53 | .context("failed to listen on socket")?; 54 | log::debug!("listening on socket {}", path.to_string_lossy()); 55 | Ok(sock) 56 | } 57 | -------------------------------------------------------------------------------- /src/bin/rbw-agent/timeout.rs: -------------------------------------------------------------------------------- 1 | use futures_util::StreamExt as _; 2 | 3 | #[derive(Debug, Hash, Eq, PartialEq, Copy, Clone)] 4 | enum Streams { 5 | Requests, 6 | Timer, 7 | } 8 | 9 | #[derive(Debug)] 10 | enum Action { 11 | Set(std::time::Duration), 12 | Clear, 13 | } 14 | 15 | pub struct Timeout { 16 | req_w: tokio::sync::mpsc::UnboundedSender, 17 | } 18 | 19 | impl Timeout { 20 | pub fn new() -> (Self, tokio::sync::mpsc::UnboundedReceiver<()>) { 21 | let (req_w, req_r) = tokio::sync::mpsc::unbounded_channel(); 22 | let (timer_w, timer_r) = tokio::sync::mpsc::unbounded_channel(); 23 | tokio::spawn(async move { 24 | enum Event { 25 | Request(Action), 26 | Timer, 27 | } 28 | let mut stream = tokio_stream::StreamMap::new(); 29 | stream.insert( 30 | Streams::Requests, 31 | tokio_stream::wrappers::UnboundedReceiverStream::new(req_r) 32 | .map(Event::Request) 33 | .boxed(), 34 | ); 35 | while let Some(event) = stream.next().await { 36 | match event { 37 | (_, Event::Request(Action::Set(dur))) => { 38 | stream.insert( 39 | Streams::Timer, 40 | futures_util::stream::once(tokio::time::sleep( 41 | dur, 42 | )) 43 | .map(|()| Event::Timer) 44 | .boxed(), 45 | ); 46 | } 47 | (_, Event::Request(Action::Clear)) => { 48 | stream.remove(&Streams::Timer); 49 | } 50 | (_, Event::Timer) => { 51 | timer_w.send(()).unwrap(); 52 | } 53 | } 54 | } 55 | }); 56 | (Self { req_w }, timer_r) 57 | } 58 | 59 | pub fn set(&self, dur: std::time::Duration) { 60 | self.req_w.send(Action::Set(dur)).unwrap(); 61 | } 62 | 63 | pub fn clear(&self) { 64 | self.req_w.send(Action::Clear).unwrap(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/bin/rbw/actions.rs: -------------------------------------------------------------------------------- 1 | use std::{io::Read as _, os::unix::ffi::OsStringExt as _}; 2 | 3 | use anyhow::Context as _; 4 | 5 | pub fn register() -> anyhow::Result<()> { 6 | simple_action(rbw::protocol::Action::Register) 7 | } 8 | 9 | pub fn login() -> anyhow::Result<()> { 10 | simple_action(rbw::protocol::Action::Login) 11 | } 12 | 13 | pub fn unlock() -> anyhow::Result<()> { 14 | simple_action(rbw::protocol::Action::Unlock) 15 | } 16 | 17 | pub fn unlocked() -> anyhow::Result<()> { 18 | match crate::sock::Sock::connect() { 19 | Ok(mut sock) => { 20 | sock.send(&rbw::protocol::Request::new( 21 | get_environment(), 22 | rbw::protocol::Action::CheckLock, 23 | ))?; 24 | 25 | let res = sock.recv()?; 26 | match res { 27 | rbw::protocol::Response::Ack => Ok(()), 28 | rbw::protocol::Response::Error { error } => { 29 | Err(anyhow::anyhow!("{}", error)) 30 | } 31 | _ => Err(anyhow::anyhow!("unexpected message: {:?}", res)), 32 | } 33 | } 34 | Err(e) => { 35 | if matches!( 36 | e.kind(), 37 | std::io::ErrorKind::ConnectionRefused 38 | | std::io::ErrorKind::NotFound 39 | ) { 40 | anyhow::bail!("agent not running"); 41 | } 42 | Err(e.into()) 43 | } 44 | } 45 | } 46 | 47 | pub fn sync() -> anyhow::Result<()> { 48 | simple_action(rbw::protocol::Action::Sync) 49 | } 50 | 51 | pub fn lock() -> anyhow::Result<()> { 52 | simple_action(rbw::protocol::Action::Lock) 53 | } 54 | 55 | pub fn quit() -> anyhow::Result<()> { 56 | match crate::sock::Sock::connect() { 57 | Ok(mut sock) => { 58 | let pidfile = rbw::dirs::pid_file(); 59 | let mut pid = String::new(); 60 | std::fs::File::open(pidfile)?.read_to_string(&mut pid)?; 61 | let Some(pid) = 62 | rustix::process::Pid::from_raw(pid.trim_end().parse()?) 63 | else { 64 | anyhow::bail!("failed to read pid from pidfile"); 65 | }; 66 | sock.send(&rbw::protocol::Request::new( 67 | get_environment(), 68 | rbw::protocol::Action::Quit, 69 | ))?; 70 | wait_for_exit(pid); 71 | Ok(()) 72 | } 73 | Err(e) => match e.kind() { 74 | // if the socket doesn't exist, or the socket exists but nothing 75 | // is listening on it, the agent must already be not running 76 | std::io::ErrorKind::ConnectionRefused 77 | | std::io::ErrorKind::NotFound => Ok(()), 78 | _ => Err(e.into()), 79 | }, 80 | } 81 | } 82 | 83 | pub fn decrypt( 84 | cipherstring: &str, 85 | entry_key: Option<&str>, 86 | org_id: Option<&str>, 87 | ) -> anyhow::Result { 88 | let mut sock = connect()?; 89 | sock.send(&rbw::protocol::Request::new( 90 | get_environment(), 91 | rbw::protocol::Action::Decrypt { 92 | cipherstring: cipherstring.to_string(), 93 | entry_key: entry_key.map(std::string::ToString::to_string), 94 | org_id: org_id.map(std::string::ToString::to_string), 95 | }, 96 | ))?; 97 | 98 | let res = sock.recv()?; 99 | match res { 100 | rbw::protocol::Response::Decrypt { plaintext } => Ok(plaintext), 101 | rbw::protocol::Response::Error { error } => { 102 | Err(anyhow::anyhow!("failed to decrypt: {}", error)) 103 | } 104 | _ => Err(anyhow::anyhow!("unexpected message: {:?}", res)), 105 | } 106 | } 107 | 108 | pub fn encrypt( 109 | plaintext: &str, 110 | org_id: Option<&str>, 111 | ) -> anyhow::Result { 112 | let mut sock = connect()?; 113 | sock.send(&rbw::protocol::Request::new( 114 | get_environment(), 115 | rbw::protocol::Action::Encrypt { 116 | plaintext: plaintext.to_string(), 117 | org_id: org_id.map(std::string::ToString::to_string), 118 | }, 119 | ))?; 120 | 121 | let res = sock.recv()?; 122 | match res { 123 | rbw::protocol::Response::Encrypt { cipherstring } => Ok(cipherstring), 124 | rbw::protocol::Response::Error { error } => { 125 | Err(anyhow::anyhow!("failed to encrypt: {}", error)) 126 | } 127 | _ => Err(anyhow::anyhow!("unexpected message: {:?}", res)), 128 | } 129 | } 130 | 131 | pub fn clipboard_store(text: &str) -> anyhow::Result<()> { 132 | simple_action(rbw::protocol::Action::ClipboardStore { 133 | text: text.to_string(), 134 | }) 135 | } 136 | 137 | pub fn version() -> anyhow::Result { 138 | let mut sock = connect()?; 139 | sock.send(&rbw::protocol::Request::new( 140 | get_environment(), 141 | rbw::protocol::Action::Version, 142 | ))?; 143 | 144 | let res = sock.recv()?; 145 | match res { 146 | rbw::protocol::Response::Version { version } => Ok(version), 147 | rbw::protocol::Response::Error { error } => { 148 | Err(anyhow::anyhow!("failed to get version: {}", error)) 149 | } 150 | _ => Err(anyhow::anyhow!("unexpected message: {:?}", res)), 151 | } 152 | } 153 | 154 | fn simple_action(action: rbw::protocol::Action) -> anyhow::Result<()> { 155 | let mut sock = connect()?; 156 | 157 | sock.send(&rbw::protocol::Request::new(get_environment(), action))?; 158 | 159 | let res = sock.recv()?; 160 | match res { 161 | rbw::protocol::Response::Ack => Ok(()), 162 | rbw::protocol::Response::Error { error } => { 163 | Err(anyhow::anyhow!("{}", error)) 164 | } 165 | _ => Err(anyhow::anyhow!("unexpected message: {:?}", res)), 166 | } 167 | } 168 | 169 | fn connect() -> anyhow::Result { 170 | crate::sock::Sock::connect().with_context(|| { 171 | let log = rbw::dirs::agent_stderr_file(); 172 | format!( 173 | "failed to connect to rbw-agent \ 174 | (this often means that the agent failed to start; \ 175 | check {} for agent logs)", 176 | log.display() 177 | ) 178 | }) 179 | } 180 | 181 | fn wait_for_exit(pid: rustix::process::Pid) { 182 | loop { 183 | if rustix::process::test_kill_process(pid).is_err() { 184 | break; 185 | } 186 | std::thread::sleep(std::time::Duration::from_millis(10)); 187 | } 188 | } 189 | 190 | fn get_environment() -> rbw::protocol::Environment { 191 | let tty = std::env::var_os("RBW_TTY").or_else(|| { 192 | rustix::termios::ttyname(std::io::stdin(), vec![]) 193 | .ok() 194 | .map(|p| std::ffi::OsString::from_vec(p.as_bytes().to_vec())) 195 | }); 196 | 197 | let env_vars = std::env::vars_os() 198 | .filter(|(var_name, _)| { 199 | (*rbw::protocol::ENVIRONMENT_VARIABLES_OS).contains(var_name) 200 | }) 201 | .collect(); 202 | rbw::protocol::Environment::new(tty, env_vars) 203 | } 204 | -------------------------------------------------------------------------------- /src/bin/rbw/main.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write as _; 2 | 3 | use anyhow::Context as _; 4 | use clap::{CommandFactory as _, Parser as _}; 5 | 6 | mod actions; 7 | mod commands; 8 | mod sock; 9 | 10 | #[derive(Debug, clap::Parser)] 11 | #[command(version, about = "Unofficial Bitwarden CLI")] 12 | enum Opt { 13 | #[command(about = "Get or set configuration options")] 14 | Config { 15 | #[command(subcommand)] 16 | config: Config, 17 | }, 18 | 19 | #[command( 20 | about = "Register this device with the Bitwarden server", 21 | long_about = "Register this device with the Bitwarden server\n\n\ 22 | The official Bitwarden server includes bot detection to prevent \ 23 | brute force attacks. In order to avoid being detected as bot \ 24 | traffic, you will need to use this command to log in with your \ 25 | personal API key (instead of your password) first before regular \ 26 | logins will work." 27 | )] 28 | Register, 29 | 30 | #[command(about = "Log in to the Bitwarden server")] 31 | Login, 32 | 33 | #[command(about = "Unlock the local Bitwarden database")] 34 | Unlock, 35 | 36 | #[command(about = "Check if the local Bitwarden database is unlocked")] 37 | Unlocked, 38 | 39 | #[command(about = "Update the local copy of the Bitwarden database")] 40 | Sync, 41 | 42 | #[command( 43 | about = "List all entries in the local Bitwarden database", 44 | visible_alias = "ls" 45 | )] 46 | List { 47 | #[arg( 48 | long, 49 | help = "Fields to display. \ 50 | Available options are id, name, user, folder. \ 51 | Multiple fields will be separated by tabs.", 52 | default_value = "name", 53 | use_value_delimiter = true 54 | )] 55 | fields: Vec, 56 | }, 57 | 58 | #[command(about = "Display the password for a given entry")] 59 | Get { 60 | #[arg(help = "Name, URI or UUID of the entry to display", value_parser = commands::parse_needle)] 61 | needle: commands::Needle, 62 | #[arg(help = "Username of the entry to display")] 63 | user: Option, 64 | #[arg(long, help = "Folder name to search in")] 65 | folder: Option, 66 | #[arg(short, long, help = "Field to get")] 67 | field: Option, 68 | #[arg(long, help = "Display the notes in addition to the password")] 69 | full: bool, 70 | #[structopt(long, help = "Display output as JSON")] 71 | raw: bool, 72 | #[cfg(feature = "clipboard")] 73 | #[structopt(long, help = "Copy result to clipboard")] 74 | clipboard: bool, 75 | #[structopt(short, long, help = "Ignore case")] 76 | ignorecase: bool, 77 | }, 78 | 79 | #[command(about = "Search for entries")] 80 | Search { 81 | #[arg(help = "Search term to locate entries")] 82 | term: String, 83 | #[arg(long, help = "Folder name to search in")] 84 | folder: Option, 85 | }, 86 | 87 | #[command( 88 | about = "Display the authenticator code for a given entry", 89 | visible_alias = "totp" 90 | )] 91 | Code { 92 | #[arg(help = "Name, URI or UUID of the entry to display", value_parser = commands::parse_needle)] 93 | needle: commands::Needle, 94 | #[arg(help = "Username of the entry to display")] 95 | user: Option, 96 | #[arg(long, help = "Folder name to search in")] 97 | folder: Option, 98 | #[cfg(feature = "clipboard")] 99 | #[structopt(long, help = "Copy result to clipboard")] 100 | clipboard: bool, 101 | #[arg(short, long, help = "Ignore case")] 102 | ignorecase: bool, 103 | }, 104 | 105 | #[command( 106 | about = "Add a new password to the database", 107 | long_about = "Add a new password to the database\n\n\ 108 | This command will open a text editor to enter \ 109 | the password and notes. The editor to use is determined \ 110 | by the value of the $VISUAL or $EDITOR environment variables. 111 | The first line will be saved as the password and the \ 112 | remainder will be saved as a note." 113 | )] 114 | Add { 115 | #[arg(help = "Name of the password entry")] 116 | name: String, 117 | #[arg(help = "Username for the password entry")] 118 | user: Option, 119 | #[arg( 120 | long, 121 | help = "URI for the password entry", 122 | number_of_values = 1 123 | )] 124 | uri: Vec, 125 | #[arg(long, help = "Folder for the password entry")] 126 | folder: Option, 127 | }, 128 | 129 | #[command( 130 | about = "Generate a new password", 131 | long_about = "Generate a new password\n\n\ 132 | If given a password entry name, also save the generated \ 133 | password to the database.", 134 | visible_alias = "gen", 135 | group = clap::ArgGroup::new("password-type").args(&[ 136 | "no_symbols", 137 | "only_numbers", 138 | "nonconfusables", 139 | "diceware", 140 | ]) 141 | )] 142 | Generate { 143 | #[arg(help = "Length of the password to generate")] 144 | len: usize, 145 | #[arg(help = "Name of the password entry")] 146 | name: Option, 147 | #[arg(help = "Username for the password entry")] 148 | user: Option, 149 | #[arg( 150 | long, 151 | help = "URI for the password entry", 152 | number_of_values = 1 153 | )] 154 | uri: Vec, 155 | #[arg(long, help = "Folder for the password entry")] 156 | folder: Option, 157 | #[arg( 158 | long = "no-symbols", 159 | help = "Generate a password with no special characters" 160 | )] 161 | no_symbols: bool, 162 | #[arg( 163 | long = "only-numbers", 164 | help = "Generate a password consisting of only numbers" 165 | )] 166 | only_numbers: bool, 167 | #[arg( 168 | long, 169 | help = "Generate a password without visually similar \ 170 | characters (useful for passwords intended to be \ 171 | written down)" 172 | )] 173 | nonconfusables: bool, 174 | #[arg( 175 | long, 176 | help = "Generate a password of multiple dictionary \ 177 | words chosen from the EFF word list. The len \ 178 | parameter for this option will set the number \ 179 | of words to generate, rather than characters." 180 | )] 181 | diceware: bool, 182 | }, 183 | 184 | #[command( 185 | about = "Modify an existing password", 186 | long_about = "Modify an existing password\n\n\ 187 | This command will open a text editor with the existing \ 188 | password and notes of the given entry for editing. \ 189 | The editor to use is determined by the value of the \ 190 | $VISUAL or $EDITOR environment variables. The first line \ 191 | will be saved as the password and the remainder will be saved \ 192 | as a note." 193 | )] 194 | Edit { 195 | #[arg(help = "Name or UUID of the password entry")] 196 | name: String, 197 | #[arg(help = "Username for the password entry")] 198 | user: Option, 199 | #[arg(long, help = "Folder name to search in")] 200 | folder: Option, 201 | #[arg(short, long, help = "Ignore case")] 202 | ignorecase: bool, 203 | }, 204 | 205 | #[command(about = "Remove a given entry", visible_alias = "rm")] 206 | Remove { 207 | #[arg(help = "Name or UUID of the password entry")] 208 | name: String, 209 | #[arg(help = "Username for the password entry")] 210 | user: Option, 211 | #[arg(long, help = "Folder name to search in")] 212 | folder: Option, 213 | #[arg(short, long, help = "Ignore case")] 214 | ignorecase: bool, 215 | }, 216 | 217 | #[command(about = "View the password history for a given entry")] 218 | History { 219 | #[arg(help = "Name or UUID of the password entry")] 220 | name: String, 221 | #[arg(help = "Username for the password entry")] 222 | user: Option, 223 | #[arg(long, help = "Folder name to search in")] 224 | folder: Option, 225 | #[arg(short, long, help = "Ignore case")] 226 | ignorecase: bool, 227 | }, 228 | 229 | #[command(about = "Lock the password database")] 230 | Lock, 231 | 232 | #[command(about = "Remove the local copy of the password database")] 233 | Purge, 234 | 235 | #[command(name = "stop-agent", about = "Terminate the background agent")] 236 | StopAgent, 237 | 238 | #[command( 239 | name = "gen-completions", 240 | about = "Generate completion script for the given shell" 241 | )] 242 | GenCompletions { shell: clap_complete::Shell }, 243 | } 244 | 245 | impl Opt { 246 | fn subcommand_name(&self) -> String { 247 | match self { 248 | Self::Config { config } => { 249 | format!("config {}", config.subcommand_name()) 250 | } 251 | Self::Register => "register".to_string(), 252 | Self::Login => "login".to_string(), 253 | Self::Unlock => "unlock".to_string(), 254 | Self::Unlocked => "unlocked".to_string(), 255 | Self::Sync => "sync".to_string(), 256 | Self::List { .. } => "list".to_string(), 257 | Self::Get { .. } => "get".to_string(), 258 | Self::Search { .. } => "search".to_string(), 259 | Self::Code { .. } => "code".to_string(), 260 | Self::Add { .. } => "add".to_string(), 261 | Self::Generate { .. } => "generate".to_string(), 262 | Self::Edit { .. } => "edit".to_string(), 263 | Self::Remove { .. } => "remove".to_string(), 264 | Self::History { .. } => "history".to_string(), 265 | Self::Lock => "lock".to_string(), 266 | Self::Purge => "purge".to_string(), 267 | Self::StopAgent => "stop-agent".to_string(), 268 | Self::GenCompletions { .. } => "gen-completions".to_string(), 269 | } 270 | } 271 | } 272 | 273 | #[derive(Debug, clap::Parser)] 274 | enum Config { 275 | #[command(about = "Show the values of all configuration settings")] 276 | Show, 277 | #[command(about = "Set a configuration option")] 278 | Set { 279 | #[arg(help = "Configuration key to set")] 280 | key: String, 281 | #[arg(help = "Value to set the configuration option to")] 282 | value: String, 283 | }, 284 | #[command(about = "Reset a configuration option to its default")] 285 | Unset { 286 | #[arg(help = "Configuration key to unset")] 287 | key: String, 288 | }, 289 | } 290 | 291 | impl Config { 292 | fn subcommand_name(&self) -> String { 293 | match self { 294 | Self::Show => "show", 295 | Self::Set { .. } => "set", 296 | Self::Unset { .. } => "unset", 297 | } 298 | .to_string() 299 | } 300 | } 301 | 302 | fn main() { 303 | let opt = Opt::parse(); 304 | 305 | env_logger::Builder::from_env( 306 | env_logger::Env::default().default_filter_or("info"), 307 | ) 308 | .format(|buf, record| { 309 | if let Some((terminal_size::Width(w), _)) = 310 | terminal_size::terminal_size() 311 | { 312 | let out = format!("{}: {}", record.level(), record.args()); 313 | writeln!(buf, "{}", textwrap::fill(&out, usize::from(w) - 1)) 314 | } else { 315 | writeln!(buf, "{}: {}", record.level(), record.args()) 316 | } 317 | }) 318 | .init(); 319 | 320 | let res = match &opt { 321 | Opt::Config { config } => match config { 322 | Config::Show => commands::config_show(), 323 | Config::Set { key, value } => commands::config_set(key, value), 324 | Config::Unset { key } => commands::config_unset(key), 325 | }, 326 | Opt::Register => commands::register(), 327 | Opt::Login => commands::login(), 328 | Opt::Unlock => commands::unlock(), 329 | Opt::Unlocked => commands::unlocked(), 330 | Opt::Sync => commands::sync(), 331 | Opt::List { fields } => commands::list(fields), 332 | Opt::Get { 333 | needle, 334 | user, 335 | folder, 336 | field, 337 | full, 338 | raw, 339 | #[cfg(feature = "clipboard")] 340 | clipboard, 341 | ignorecase, 342 | } => commands::get( 343 | needle, 344 | user.as_deref(), 345 | folder.as_deref(), 346 | field.as_deref(), 347 | *full, 348 | *raw, 349 | #[cfg(feature = "clipboard")] 350 | *clipboard, 351 | #[cfg(not(feature = "clipboard"))] 352 | false, 353 | *ignorecase, 354 | ), 355 | Opt::Search { term, folder } => { 356 | commands::search(term, folder.as_deref()) 357 | } 358 | Opt::Code { 359 | needle, 360 | user, 361 | folder, 362 | #[cfg(feature = "clipboard")] 363 | clipboard, 364 | ignorecase, 365 | } => commands::code( 366 | needle, 367 | user.as_deref(), 368 | folder.as_deref(), 369 | #[cfg(feature = "clipboard")] 370 | *clipboard, 371 | #[cfg(not(feature = "clipboard"))] 372 | false, 373 | *ignorecase, 374 | ), 375 | Opt::Add { 376 | name, 377 | user, 378 | uri, 379 | folder, 380 | } => commands::add( 381 | name, 382 | user.as_deref(), 383 | &uri.iter() 384 | // XXX not sure what the ui for specifying the match type 385 | // should be 386 | .map(|uri| (uri.clone(), None)) 387 | .collect::>(), 388 | folder.as_deref(), 389 | ), 390 | Opt::Generate { 391 | len, 392 | name, 393 | user, 394 | uri, 395 | folder, 396 | no_symbols, 397 | only_numbers, 398 | nonconfusables, 399 | diceware, 400 | } => { 401 | let ty = if *no_symbols { 402 | rbw::pwgen::Type::NoSymbols 403 | } else if *only_numbers { 404 | rbw::pwgen::Type::Numbers 405 | } else if *nonconfusables { 406 | rbw::pwgen::Type::NonConfusables 407 | } else if *diceware { 408 | rbw::pwgen::Type::Diceware 409 | } else { 410 | rbw::pwgen::Type::AllChars 411 | }; 412 | commands::generate( 413 | name.as_deref(), 414 | user.as_deref(), 415 | &uri.iter() 416 | // XXX not sure what the ui for specifying the match type 417 | // should be 418 | .map(|uri| (uri.clone(), None)) 419 | .collect::>(), 420 | folder.as_deref(), 421 | *len, 422 | ty, 423 | ) 424 | } 425 | Opt::Edit { 426 | name, 427 | user, 428 | folder, 429 | ignorecase, 430 | } => commands::edit( 431 | name, 432 | user.as_deref(), 433 | folder.as_deref(), 434 | *ignorecase, 435 | ), 436 | Opt::Remove { 437 | name, 438 | user, 439 | folder, 440 | ignorecase, 441 | } => commands::remove( 442 | name, 443 | user.as_deref(), 444 | folder.as_deref(), 445 | *ignorecase, 446 | ), 447 | Opt::History { 448 | name, 449 | user, 450 | folder, 451 | ignorecase, 452 | } => commands::history( 453 | name, 454 | user.as_deref(), 455 | folder.as_deref(), 456 | *ignorecase, 457 | ), 458 | Opt::Lock => commands::lock(), 459 | Opt::Purge => commands::purge(), 460 | Opt::StopAgent => commands::stop_agent(), 461 | Opt::GenCompletions { shell } => { 462 | clap_complete::generate( 463 | *shell, 464 | &mut Opt::command(), 465 | "rbw", 466 | &mut std::io::stdout(), 467 | ); 468 | Ok(()) 469 | } 470 | } 471 | .context(format!("rbw {}", opt.subcommand_name())); 472 | 473 | if let Err(e) = res { 474 | eprintln!("{e:#}"); 475 | std::process::exit(1); 476 | } 477 | } 478 | -------------------------------------------------------------------------------- /src/bin/rbw/sock.rs: -------------------------------------------------------------------------------- 1 | use std::io::{BufRead as _, Write as _}; 2 | 3 | use anyhow::Context as _; 4 | 5 | pub struct Sock(std::os::unix::net::UnixStream); 6 | 7 | impl Sock { 8 | // not returning anyhow::Result here because we want to be able to handle 9 | // specific kinds of std::io::Results differently 10 | pub fn connect() -> std::io::Result { 11 | Ok(Self(std::os::unix::net::UnixStream::connect( 12 | rbw::dirs::socket_file(), 13 | )?)) 14 | } 15 | 16 | pub fn send( 17 | &mut self, 18 | msg: &rbw::protocol::Request, 19 | ) -> anyhow::Result<()> { 20 | let Self(sock) = self; 21 | sock.write_all( 22 | serde_json::to_string(msg) 23 | .context("failed to serialize message to agent")? 24 | .as_bytes(), 25 | ) 26 | .context("failed to send message to agent")?; 27 | sock.write_all(b"\n") 28 | .context("failed to send message to agent")?; 29 | Ok(()) 30 | } 31 | 32 | pub fn recv(&mut self) -> anyhow::Result { 33 | let Self(sock) = self; 34 | let mut buf = std::io::BufReader::new(sock); 35 | let mut line = String::new(); 36 | buf.read_line(&mut line) 37 | .context("failed to read message from agent")?; 38 | serde_json::from_str(&line) 39 | .context("failed to parse message from agent") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/cipherstring.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use aes::cipher::{ 4 | BlockDecryptMut as _, BlockEncryptMut as _, KeyIvInit as _, 5 | }; 6 | use hmac::Mac as _; 7 | use pkcs8::DecodePrivateKey as _; 8 | use rand::RngCore as _; 9 | use zeroize::Zeroize as _; 10 | 11 | pub enum CipherString { 12 | Symmetric { 13 | // ty: 2 (AES_256_CBC_HMAC_SHA256) 14 | iv: Vec, 15 | ciphertext: Vec, 16 | mac: Option>, 17 | }, 18 | Asymmetric { 19 | // ty: 4 (RSA_2048_OAEP_SHA1) 20 | ciphertext: Vec, 21 | }, 22 | } 23 | 24 | impl CipherString { 25 | pub fn new(s: &str) -> Result { 26 | let parts: Vec<&str> = s.split('.').collect(); 27 | if parts.len() != 2 { 28 | return Err(Error::InvalidCipherString { 29 | reason: "couldn't find type".to_string(), 30 | }); 31 | } 32 | 33 | let ty = parts[0].as_bytes(); 34 | if ty.len() != 1 { 35 | return Err(Error::UnimplementedCipherStringType { 36 | ty: parts[0].to_string(), 37 | }); 38 | } 39 | 40 | let ty = ty[0] - b'0'; 41 | let contents = parts[1]; 42 | 43 | match ty { 44 | 2 => { 45 | let parts: Vec<&str> = contents.split('|').collect(); 46 | if parts.len() < 2 || parts.len() > 3 { 47 | return Err(Error::InvalidCipherString { 48 | reason: format!( 49 | "type 2 cipherstring with {} parts", 50 | parts.len() 51 | ), 52 | }); 53 | } 54 | 55 | let iv = crate::base64::decode(parts[0]) 56 | .map_err(|source| Error::InvalidBase64 { source })?; 57 | let ciphertext = crate::base64::decode(parts[1]) 58 | .map_err(|source| Error::InvalidBase64 { source })?; 59 | let mac = 60 | if parts.len() > 2 { 61 | Some(crate::base64::decode(parts[2]).map_err( 62 | |source| Error::InvalidBase64 { source }, 63 | )?) 64 | } else { 65 | None 66 | }; 67 | 68 | Ok(Self::Symmetric { 69 | iv, 70 | ciphertext, 71 | mac, 72 | }) 73 | } 74 | 4 | 6 => { 75 | // the only difference between 4 and 6 is the HMAC256 76 | // signature appended at the end 77 | // https://github.com/bitwarden/jslib/blob/785b681f61f81690de6df55159ab07ae710bcfad/src/enums/encryptionType.ts#L8 78 | // format is: | 79 | let contents = contents.split('|').next().unwrap(); 80 | let ciphertext = crate::base64::decode(contents) 81 | .map_err(|source| Error::InvalidBase64 { source })?; 82 | Ok(Self::Asymmetric { ciphertext }) 83 | } 84 | _ => { 85 | if ty < 6 { 86 | Err(Error::TooOldCipherStringType { ty: ty.to_string() }) 87 | } else { 88 | Err(Error::UnimplementedCipherStringType { 89 | ty: ty.to_string(), 90 | }) 91 | } 92 | } 93 | } 94 | } 95 | 96 | pub fn encrypt_symmetric( 97 | keys: &crate::locked::Keys, 98 | plaintext: &[u8], 99 | ) -> Result { 100 | let iv = random_iv(); 101 | 102 | let cipher = cbc::Encryptor::::new( 103 | keys.enc_key().into(), 104 | iv.as_slice().into(), 105 | ); 106 | let ciphertext = 107 | cipher.encrypt_padded_vec_mut::(plaintext); 108 | 109 | let mut digest = 110 | hmac::Hmac::::new_from_slice(keys.mac_key()) 111 | .map_err(|source| Error::CreateHmac { source })?; 112 | digest.update(&iv); 113 | digest.update(&ciphertext); 114 | let mac = digest.finalize().into_bytes().as_slice().to_vec(); 115 | 116 | Ok(Self::Symmetric { 117 | iv, 118 | ciphertext, 119 | mac: Some(mac), 120 | }) 121 | } 122 | 123 | pub fn decrypt_symmetric( 124 | &self, 125 | keys: &crate::locked::Keys, 126 | entry_key: Option<&crate::locked::Keys>, 127 | ) -> Result> { 128 | if let Self::Symmetric { 129 | iv, 130 | ciphertext, 131 | mac, 132 | } = self 133 | { 134 | let cipher = decrypt_common_symmetric( 135 | entry_key.unwrap_or(keys), 136 | iv, 137 | ciphertext, 138 | mac.as_deref(), 139 | )?; 140 | cipher 141 | .decrypt_padded_vec_mut::(ciphertext) 142 | .map_err(|source| Error::Decrypt { source }) 143 | } else { 144 | Err(Error::InvalidCipherString { 145 | reason: 146 | "found an asymmetric cipherstring, expecting symmetric" 147 | .to_string(), 148 | }) 149 | } 150 | } 151 | 152 | pub fn decrypt_locked_symmetric( 153 | &self, 154 | keys: &crate::locked::Keys, 155 | ) -> Result { 156 | if let Self::Symmetric { 157 | iv, 158 | ciphertext, 159 | mac, 160 | } = self 161 | { 162 | let mut res = crate::locked::Vec::new(); 163 | res.extend(ciphertext.iter().copied()); 164 | let cipher = decrypt_common_symmetric( 165 | keys, 166 | iv, 167 | ciphertext, 168 | mac.as_deref(), 169 | )?; 170 | cipher 171 | .decrypt_padded_mut::(res.data_mut()) 172 | .map_err(|source| Error::Decrypt { source })?; 173 | Ok(res) 174 | } else { 175 | Err(Error::InvalidCipherString { 176 | reason: 177 | "found an asymmetric cipherstring, expecting symmetric" 178 | .to_string(), 179 | }) 180 | } 181 | } 182 | 183 | pub fn decrypt_locked_asymmetric( 184 | &self, 185 | private_key: &crate::locked::PrivateKey, 186 | ) -> Result { 187 | if let Self::Asymmetric { ciphertext } = self { 188 | let privkey_data = private_key.private_key(); 189 | let privkey_data = 190 | pkcs7_unpad(privkey_data).ok_or(Error::Padding)?; 191 | let pkey = rsa::RsaPrivateKey::from_pkcs8_der(privkey_data) 192 | .map_err(|source| Error::RsaPkcs8 { source })?; 193 | let mut bytes = pkey 194 | .decrypt(rsa::Oaep::new::(), ciphertext) 195 | .map_err(|source| Error::Rsa { source })?; 196 | 197 | // XXX it'd be great if the rsa crate would let us decrypt 198 | // into a preallocated buffer directly to avoid the 199 | // intermediate vec that needs to be manually zeroized, etc 200 | let mut res = crate::locked::Vec::new(); 201 | res.extend(bytes.iter().copied()); 202 | bytes.zeroize(); 203 | 204 | Ok(res) 205 | } else { 206 | Err(Error::InvalidCipherString { 207 | reason: 208 | "found a symmetric cipherstring, expecting asymmetric" 209 | .to_string(), 210 | }) 211 | } 212 | } 213 | } 214 | 215 | fn decrypt_common_symmetric( 216 | keys: &crate::locked::Keys, 217 | iv: &[u8], 218 | ciphertext: &[u8], 219 | mac: Option<&[u8]>, 220 | ) -> Result> { 221 | if let Some(mac) = mac { 222 | let mut key = 223 | hmac::Hmac::::new_from_slice(keys.mac_key()) 224 | .map_err(|source| Error::CreateHmac { source })?; 225 | key.update(iv); 226 | key.update(ciphertext); 227 | 228 | if key.verify(mac.into()).is_err() { 229 | return Err(Error::InvalidMac); 230 | } 231 | } 232 | 233 | cbc::Decryptor::::new_from_slices(keys.enc_key(), iv) 234 | .map_err(|source| Error::CreateBlockMode { source }) 235 | } 236 | 237 | impl std::fmt::Display for CipherString { 238 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 239 | match self { 240 | Self::Symmetric { 241 | iv, 242 | ciphertext, 243 | mac, 244 | } => { 245 | let iv = crate::base64::encode(iv); 246 | let ciphertext = crate::base64::encode(ciphertext); 247 | if let Some(mac) = &mac { 248 | let mac = crate::base64::encode(mac); 249 | write!(f, "2.{iv}|{ciphertext}|{mac}") 250 | } else { 251 | write!(f, "2.{iv}|{ciphertext}") 252 | } 253 | } 254 | Self::Asymmetric { ciphertext } => { 255 | let ciphertext = crate::base64::encode(ciphertext); 256 | write!(f, "4.{ciphertext}") 257 | } 258 | } 259 | } 260 | } 261 | 262 | fn random_iv() -> Vec { 263 | let mut iv = vec![0_u8; 16]; 264 | let mut rng = rand::thread_rng(); 265 | rng.fill_bytes(&mut iv); 266 | iv 267 | } 268 | 269 | // XXX this should ideally just be block_padding::Pkcs7::unpad, but i can't 270 | // figure out how to get the generic types to work out 271 | fn pkcs7_unpad(b: &[u8]) -> Option<&[u8]> { 272 | if b.is_empty() { 273 | return None; 274 | } 275 | 276 | let padding_val = b[b.len() - 1]; 277 | if padding_val == 0 { 278 | return None; 279 | } 280 | 281 | let padding_len = usize::from(padding_val); 282 | if padding_len > b.len() { 283 | return None; 284 | } 285 | 286 | for c in b.iter().copied().skip(b.len() - padding_len) { 287 | if c != padding_val { 288 | return None; 289 | } 290 | } 291 | 292 | Some(&b[..b.len() - padding_len]) 293 | } 294 | 295 | #[test] 296 | fn test_pkcs7_unpad() { 297 | let tests = [ 298 | (&[][..], None), 299 | (&[0x01][..], Some(&[][..])), 300 | (&[0x02, 0x02][..], Some(&[][..])), 301 | (&[0x03, 0x03, 0x03][..], Some(&[][..])), 302 | (&[0x69, 0x01][..], Some(&[0x69][..])), 303 | (&[0x69, 0x02, 0x02][..], Some(&[0x69][..])), 304 | (&[0x69, 0x03, 0x03, 0x03][..], Some(&[0x69][..])), 305 | (&[0x02][..], None), 306 | (&[0x03][..], None), 307 | (&[0x69, 0x69, 0x03, 0x03][..], None), 308 | (&[0x00][..], None), 309 | (&[0x02, 0x00][..], None), 310 | ]; 311 | for (input, expected) in tests { 312 | let got = pkcs7_unpad(input); 313 | assert_eq!(got, expected); 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use std::io::{Read as _, Write as _}; 4 | 5 | use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; 6 | 7 | #[derive(serde::Serialize, serde::Deserialize, Debug)] 8 | pub struct Config { 9 | pub email: Option, 10 | pub sso_id: Option, 11 | pub base_url: Option, 12 | pub identity_url: Option, 13 | pub ui_url: Option, 14 | pub notifications_url: Option, 15 | #[serde(default = "default_lock_timeout")] 16 | pub lock_timeout: u64, 17 | #[serde(default = "default_sync_interval")] 18 | pub sync_interval: u64, 19 | #[serde(default = "default_pinentry")] 20 | pub pinentry: String, 21 | pub client_cert_path: Option, 22 | // backcompat, no longer generated in new configs 23 | #[serde(skip_serializing)] 24 | pub device_id: Option, 25 | } 26 | 27 | impl Default for Config { 28 | fn default() -> Self { 29 | Self { 30 | email: None, 31 | sso_id: None, 32 | base_url: None, 33 | identity_url: None, 34 | ui_url: None, 35 | notifications_url: None, 36 | lock_timeout: default_lock_timeout(), 37 | sync_interval: default_sync_interval(), 38 | pinentry: default_pinentry(), 39 | client_cert_path: None, 40 | device_id: None, 41 | } 42 | } 43 | } 44 | 45 | pub fn default_lock_timeout() -> u64 { 46 | 3600 47 | } 48 | 49 | pub fn default_sync_interval() -> u64 { 50 | 3600 51 | } 52 | 53 | pub fn default_pinentry() -> String { 54 | "pinentry".to_string() 55 | } 56 | 57 | impl Config { 58 | pub fn new() -> Self { 59 | Self::default() 60 | } 61 | 62 | pub fn load() -> Result { 63 | let file = crate::dirs::config_file(); 64 | let mut fh = std::fs::File::open(&file).map_err(|source| { 65 | Error::LoadConfig { 66 | source, 67 | file: file.clone(), 68 | } 69 | })?; 70 | let mut json = String::new(); 71 | fh.read_to_string(&mut json) 72 | .map_err(|source| Error::LoadConfig { 73 | source, 74 | file: file.clone(), 75 | })?; 76 | let mut slf: Self = serde_json::from_str(&json) 77 | .map_err(|source| Error::LoadConfigJson { source, file })?; 78 | if slf.lock_timeout == 0 { 79 | log::warn!("lock_timeout must be greater than 0"); 80 | slf.lock_timeout = default_lock_timeout(); 81 | } 82 | Ok(slf) 83 | } 84 | 85 | pub async fn load_async() -> Result { 86 | let file = crate::dirs::config_file(); 87 | let mut fh = 88 | tokio::fs::File::open(&file).await.map_err(|source| { 89 | Error::LoadConfigAsync { 90 | source, 91 | file: file.clone(), 92 | } 93 | })?; 94 | let mut json = String::new(); 95 | fh.read_to_string(&mut json).await.map_err(|source| { 96 | Error::LoadConfigAsync { 97 | source, 98 | file: file.clone(), 99 | } 100 | })?; 101 | let mut slf: Self = serde_json::from_str(&json) 102 | .map_err(|source| Error::LoadConfigJson { source, file })?; 103 | if slf.lock_timeout == 0 { 104 | log::warn!("lock_timeout must be greater than 0"); 105 | slf.lock_timeout = default_lock_timeout(); 106 | } 107 | Ok(slf) 108 | } 109 | 110 | pub fn save(&self) -> Result<()> { 111 | let file = crate::dirs::config_file(); 112 | // unwrap is safe here because Self::filename is explicitly 113 | // constructed as a filename in a directory 114 | std::fs::create_dir_all(file.parent().unwrap()).map_err( 115 | |source| Error::SaveConfig { 116 | source, 117 | file: file.clone(), 118 | }, 119 | )?; 120 | let mut fh = std::fs::File::create(&file).map_err(|source| { 121 | Error::SaveConfig { 122 | source, 123 | file: file.clone(), 124 | } 125 | })?; 126 | fh.write_all( 127 | serde_json::to_string(self) 128 | .map_err(|source| Error::SaveConfigJson { 129 | source, 130 | file: file.clone(), 131 | })? 132 | .as_bytes(), 133 | ) 134 | .map_err(|source| Error::SaveConfig { source, file })?; 135 | Ok(()) 136 | } 137 | 138 | pub fn validate() -> Result<()> { 139 | let config = Self::load()?; 140 | if config.email.is_none() { 141 | return Err(Error::ConfigMissingEmail); 142 | } 143 | Ok(()) 144 | } 145 | 146 | pub fn base_url(&self) -> String { 147 | self.base_url.clone().map_or_else( 148 | || "https://api.bitwarden.com".to_string(), 149 | |url| { 150 | let clean_url = url.trim_end_matches('/').to_string(); 151 | if clean_url == "https://api.bitwarden.eu" { 152 | clean_url 153 | } else { 154 | format!("{clean_url}/api") 155 | } 156 | }, 157 | ) 158 | } 159 | 160 | pub fn identity_url(&self) -> String { 161 | self.identity_url.clone().unwrap_or_else(|| { 162 | self.base_url.clone().map_or_else( 163 | || "https://identity.bitwarden.com".to_string(), 164 | |url| { 165 | let clean_url = url.trim_end_matches('/').to_string(); 166 | if clean_url == "https://identity.bitwarden.eu" { 167 | clean_url 168 | } else { 169 | format!("{clean_url}/identity") 170 | } 171 | }, 172 | ) 173 | }) 174 | } 175 | 176 | pub fn ui_url(&self) -> String { 177 | // TODO: default to either vault.bitwarden.com or vault.bitwarden.eu based on the base_url? 178 | self.ui_url 179 | .clone() 180 | .unwrap_or_else(|| "https://vault.bitwarden.com".to_string()) 181 | } 182 | 183 | pub fn notifications_url(&self) -> String { 184 | self.notifications_url.clone().unwrap_or_else(|| { 185 | self.base_url.clone().map_or_else( 186 | || "https://notifications.bitwarden.com".to_string(), 187 | |url| { 188 | let clean_url = url.trim_end_matches('/').to_string(); 189 | if clean_url == "https://notifications.bitwarden.eu" { 190 | clean_url 191 | } else { 192 | format!("{clean_url}/notifications") 193 | } 194 | }, 195 | ) 196 | }) 197 | } 198 | 199 | pub fn client_cert_path(&self) -> Option<&std::path::Path> { 200 | self.client_cert_path.as_deref() 201 | } 202 | 203 | pub fn server_name(&self) -> String { 204 | self.base_url 205 | .clone() 206 | .unwrap_or_else(|| "default".to_string()) 207 | } 208 | } 209 | 210 | pub async fn device_id(config: &Config) -> Result { 211 | let file = crate::dirs::device_id_file(); 212 | if let Ok(mut fh) = tokio::fs::File::open(&file).await { 213 | let mut s = String::new(); 214 | fh.read_to_string(&mut s) 215 | .await 216 | .map_err(|e| Error::LoadDeviceId { 217 | source: e, 218 | file: file.clone(), 219 | })?; 220 | Ok(s.trim().to_string()) 221 | } else { 222 | let id = config.device_id.as_ref().map_or_else( 223 | || uuid::Uuid::new_v4().hyphenated().to_string(), 224 | String::to_string, 225 | ); 226 | let mut fh = tokio::fs::File::create(&file).await.map_err(|e| { 227 | Error::LoadDeviceId { 228 | source: e, 229 | file: file.clone(), 230 | } 231 | })?; 232 | fh.write_all(id.as_bytes()).await.map_err(|e| { 233 | Error::LoadDeviceId { 234 | source: e, 235 | file: file.clone(), 236 | } 237 | })?; 238 | Ok(id) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/db.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use std::io::{Read as _, Write as _}; 4 | 5 | use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; 6 | 7 | #[derive( 8 | serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, 9 | )] 10 | pub struct Entry { 11 | pub id: String, 12 | pub org_id: Option, 13 | pub folder: Option, 14 | pub folder_id: Option, 15 | pub name: String, 16 | pub data: EntryData, 17 | pub fields: Vec, 18 | pub notes: Option, 19 | pub history: Vec, 20 | pub key: Option, 21 | } 22 | 23 | #[derive(serde::Serialize, Debug, Clone, Eq, PartialEq)] 24 | pub struct Uri { 25 | pub uri: String, 26 | pub match_type: Option, 27 | } 28 | 29 | // backwards compatibility 30 | impl<'de> serde::Deserialize<'de> for Uri { 31 | fn deserialize(deserializer: D) -> std::result::Result 32 | where 33 | D: serde::Deserializer<'de>, 34 | { 35 | struct StringOrUri; 36 | impl<'de> serde::de::Visitor<'de> for StringOrUri { 37 | type Value = Uri; 38 | 39 | fn expecting( 40 | &self, 41 | formatter: &mut std::fmt::Formatter, 42 | ) -> std::fmt::Result { 43 | formatter.write_str("uri") 44 | } 45 | 46 | fn visit_str( 47 | self, 48 | value: &str, 49 | ) -> std::result::Result 50 | where 51 | E: serde::de::Error, 52 | { 53 | Ok(Uri { 54 | uri: value.to_string(), 55 | match_type: None, 56 | }) 57 | } 58 | 59 | fn visit_map( 60 | self, 61 | mut map: M, 62 | ) -> std::result::Result 63 | where 64 | M: serde::de::MapAccess<'de>, 65 | { 66 | let mut uri = None; 67 | let mut match_type = None; 68 | while let Some(key) = map.next_key()? { 69 | match key { 70 | "uri" => { 71 | if uri.is_some() { 72 | return Err( 73 | serde::de::Error::duplicate_field("uri"), 74 | ); 75 | } 76 | uri = Some(map.next_value()?); 77 | } 78 | "match_type" => { 79 | if match_type.is_some() { 80 | return Err( 81 | serde::de::Error::duplicate_field( 82 | "match_type", 83 | ), 84 | ); 85 | } 86 | match_type = map.next_value()?; 87 | } 88 | _ => { 89 | return Err(serde::de::Error::unknown_field( 90 | key, 91 | &["uri", "match_type"], 92 | )) 93 | } 94 | } 95 | } 96 | 97 | uri.map_or_else( 98 | || Err(serde::de::Error::missing_field("uri")), 99 | |uri| Ok(Self::Value { uri, match_type }), 100 | ) 101 | } 102 | } 103 | 104 | deserializer.deserialize_any(StringOrUri) 105 | } 106 | } 107 | 108 | #[derive( 109 | serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, 110 | )] 111 | pub enum EntryData { 112 | Login { 113 | username: Option, 114 | password: Option, 115 | totp: Option, 116 | uris: Vec, 117 | }, 118 | Card { 119 | cardholder_name: Option, 120 | number: Option, 121 | brand: Option, 122 | exp_month: Option, 123 | exp_year: Option, 124 | code: Option, 125 | }, 126 | Identity { 127 | title: Option, 128 | first_name: Option, 129 | middle_name: Option, 130 | last_name: Option, 131 | address1: Option, 132 | address2: Option, 133 | address3: Option, 134 | city: Option, 135 | state: Option, 136 | postal_code: Option, 137 | country: Option, 138 | phone: Option, 139 | email: Option, 140 | ssn: Option, 141 | license_number: Option, 142 | passport_number: Option, 143 | username: Option, 144 | }, 145 | SecureNote, 146 | } 147 | 148 | #[derive( 149 | serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, 150 | )] 151 | pub struct Field { 152 | pub ty: Option, 153 | pub name: Option, 154 | pub value: Option, 155 | pub linked_id: Option, 156 | } 157 | 158 | #[derive( 159 | serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, 160 | )] 161 | pub struct HistoryEntry { 162 | pub last_used_date: String, 163 | pub password: String, 164 | } 165 | 166 | #[derive(serde::Serialize, serde::Deserialize, Default, Debug)] 167 | pub struct Db { 168 | pub access_token: Option, 169 | pub refresh_token: Option, 170 | 171 | pub kdf: Option, 172 | pub iterations: Option, 173 | pub memory: Option, 174 | pub parallelism: Option, 175 | pub protected_key: Option, 176 | pub protected_private_key: Option, 177 | pub protected_org_keys: std::collections::HashMap, 178 | 179 | pub entries: Vec, 180 | } 181 | 182 | impl Db { 183 | pub fn new() -> Self { 184 | Self::default() 185 | } 186 | 187 | pub fn load(server: &str, email: &str) -> Result { 188 | let file = crate::dirs::db_file(server, email); 189 | let mut fh = 190 | std::fs::File::open(&file).map_err(|source| Error::LoadDb { 191 | source, 192 | file: file.clone(), 193 | })?; 194 | let mut json = String::new(); 195 | fh.read_to_string(&mut json) 196 | .map_err(|source| Error::LoadDb { 197 | source, 198 | file: file.clone(), 199 | })?; 200 | let slf: Self = serde_json::from_str(&json) 201 | .map_err(|source| Error::LoadDbJson { source, file })?; 202 | Ok(slf) 203 | } 204 | 205 | pub async fn load_async(server: &str, email: &str) -> Result { 206 | let file = crate::dirs::db_file(server, email); 207 | let mut fh = 208 | tokio::fs::File::open(&file).await.map_err(|source| { 209 | Error::LoadDbAsync { 210 | source, 211 | file: file.clone(), 212 | } 213 | })?; 214 | let mut json = String::new(); 215 | fh.read_to_string(&mut json).await.map_err(|source| { 216 | Error::LoadDbAsync { 217 | source, 218 | file: file.clone(), 219 | } 220 | })?; 221 | let slf: Self = serde_json::from_str(&json) 222 | .map_err(|source| Error::LoadDbJson { source, file })?; 223 | Ok(slf) 224 | } 225 | 226 | // XXX need to make this atomic 227 | pub fn save(&self, server: &str, email: &str) -> Result<()> { 228 | let file = crate::dirs::db_file(server, email); 229 | // unwrap is safe here because Self::filename is explicitly 230 | // constructed as a filename in a directory 231 | std::fs::create_dir_all(file.parent().unwrap()).map_err( 232 | |source| Error::SaveDb { 233 | source, 234 | file: file.clone(), 235 | }, 236 | )?; 237 | let mut fh = 238 | std::fs::File::create(&file).map_err(|source| Error::SaveDb { 239 | source, 240 | file: file.clone(), 241 | })?; 242 | fh.write_all( 243 | serde_json::to_string(self) 244 | .map_err(|source| Error::SaveDbJson { 245 | source, 246 | file: file.clone(), 247 | })? 248 | .as_bytes(), 249 | ) 250 | .map_err(|source| Error::SaveDb { source, file })?; 251 | Ok(()) 252 | } 253 | 254 | // XXX need to make this atomic 255 | pub async fn save_async(&self, server: &str, email: &str) -> Result<()> { 256 | let file = crate::dirs::db_file(server, email); 257 | // unwrap is safe here because Self::filename is explicitly 258 | // constructed as a filename in a directory 259 | tokio::fs::create_dir_all(file.parent().unwrap()) 260 | .await 261 | .map_err(|source| Error::SaveDbAsync { 262 | source, 263 | file: file.clone(), 264 | })?; 265 | let mut fh = 266 | tokio::fs::File::create(&file).await.map_err(|source| { 267 | Error::SaveDbAsync { 268 | source, 269 | file: file.clone(), 270 | } 271 | })?; 272 | fh.write_all( 273 | serde_json::to_string(self) 274 | .map_err(|source| Error::SaveDbJson { 275 | source, 276 | file: file.clone(), 277 | })? 278 | .as_bytes(), 279 | ) 280 | .await 281 | .map_err(|source| Error::SaveDbAsync { source, file })?; 282 | Ok(()) 283 | } 284 | 285 | pub fn remove(server: &str, email: &str) -> Result<()> { 286 | let file = crate::dirs::db_file(server, email); 287 | let res = std::fs::remove_file(&file); 288 | if let Err(e) = &res { 289 | if e.kind() == std::io::ErrorKind::NotFound { 290 | return Ok(()); 291 | } 292 | } 293 | res.map_err(|source| Error::RemoveDb { source, file })?; 294 | Ok(()) 295 | } 296 | 297 | pub fn needs_login(&self) -> bool { 298 | self.access_token.is_none() 299 | || self.refresh_token.is_none() 300 | || self.iterations.is_none() 301 | || self.kdf.is_none() 302 | || self.protected_key.is_none() 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /src/dirs.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use std::os::unix::fs::{DirBuilderExt as _, PermissionsExt as _}; 4 | 5 | pub fn make_all() -> Result<()> { 6 | create_dir_all_with_permissions(&cache_dir(), 0o700)?; 7 | create_dir_all_with_permissions(&runtime_dir(), 0o700)?; 8 | create_dir_all_with_permissions(&data_dir(), 0o700)?; 9 | 10 | Ok(()) 11 | } 12 | 13 | fn create_dir_all_with_permissions( 14 | path: &std::path::Path, 15 | mode: u32, 16 | ) -> Result<()> { 17 | // ensure the initial directory creation happens with the correct mode, 18 | // to avoid race conditions 19 | std::fs::DirBuilder::new() 20 | .recursive(true) 21 | .mode(mode) 22 | .create(path) 23 | .map_err(|source| Error::CreateDirectory { 24 | source, 25 | file: path.to_path_buf(), 26 | })?; 27 | // but also make sure to forcibly set the mode, in case the directory 28 | // already existed 29 | std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) 30 | .map_err(|source| Error::CreateDirectory { 31 | source, 32 | file: path.to_path_buf(), 33 | })?; 34 | Ok(()) 35 | } 36 | 37 | pub fn config_file() -> std::path::PathBuf { 38 | config_dir().join("config.json") 39 | } 40 | 41 | const INVALID_PATH: &percent_encoding::AsciiSet = 42 | &percent_encoding::CONTROLS.add(b'/').add(b'%').add(b':'); 43 | pub fn db_file(server: &str, email: &str) -> std::path::PathBuf { 44 | let server = 45 | percent_encoding::percent_encode(server.as_bytes(), INVALID_PATH) 46 | .to_string(); 47 | cache_dir().join(format!("{server}:{email}.json")) 48 | } 49 | 50 | pub fn pid_file() -> std::path::PathBuf { 51 | runtime_dir().join("pidfile") 52 | } 53 | 54 | pub fn agent_stdout_file() -> std::path::PathBuf { 55 | data_dir().join("agent.out") 56 | } 57 | 58 | pub fn agent_stderr_file() -> std::path::PathBuf { 59 | data_dir().join("agent.err") 60 | } 61 | 62 | pub fn device_id_file() -> std::path::PathBuf { 63 | data_dir().join("device_id") 64 | } 65 | 66 | pub fn socket_file() -> std::path::PathBuf { 67 | runtime_dir().join("socket") 68 | } 69 | 70 | fn config_dir() -> std::path::PathBuf { 71 | let project_dirs = 72 | directories::ProjectDirs::from("", "", &profile()).unwrap(); 73 | project_dirs.config_dir().to_path_buf() 74 | } 75 | 76 | fn cache_dir() -> std::path::PathBuf { 77 | let project_dirs = 78 | directories::ProjectDirs::from("", "", &profile()).unwrap(); 79 | project_dirs.cache_dir().to_path_buf() 80 | } 81 | 82 | fn data_dir() -> std::path::PathBuf { 83 | let project_dirs = 84 | directories::ProjectDirs::from("", "", &profile()).unwrap(); 85 | project_dirs.data_dir().to_path_buf() 86 | } 87 | 88 | fn runtime_dir() -> std::path::PathBuf { 89 | let project_dirs = 90 | directories::ProjectDirs::from("", "", &profile()).unwrap(); 91 | project_dirs.runtime_dir().map_or_else( 92 | || { 93 | format!( 94 | "{}/{}-{}", 95 | std::env::temp_dir().to_string_lossy(), 96 | &profile(), 97 | rustix::process::getuid().as_raw() 98 | ) 99 | .into() 100 | }, 101 | std::path::Path::to_path_buf, 102 | ) 103 | } 104 | 105 | pub fn profile() -> String { 106 | match std::env::var("RBW_PROFILE") { 107 | Ok(profile) if !profile.is_empty() => format!("rbw-{profile}"), 108 | _ => "rbw".to_string(), 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/edit.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use std::io::{Read as _, Write as _}; 4 | 5 | use is_terminal::IsTerminal as _; 6 | 7 | pub fn edit(contents: &str, help: &str) -> Result { 8 | if !std::io::stdin().is_terminal() { 9 | // directly read from piped content 10 | return match std::io::read_to_string(std::io::stdin()) { 11 | Err(e) => Err(Error::FailedToReadFromStdin { err: e }), 12 | Ok(res) => Ok(res), 13 | }; 14 | } 15 | 16 | let mut var = "VISUAL"; 17 | let editor = std::env::var_os(var).unwrap_or_else(|| { 18 | var = "EDITOR"; 19 | std::env::var_os(var).unwrap_or_else(|| "/usr/bin/vim".into()) 20 | }); 21 | 22 | let dir = tempfile::tempdir().unwrap(); 23 | let file = dir.path().join("rbw"); 24 | let mut fh = std::fs::File::create(&file).unwrap(); 25 | fh.write_all(contents.as_bytes()).unwrap(); 26 | fh.write_all(help.as_bytes()).unwrap(); 27 | drop(fh); 28 | 29 | let (cmd, args) = if contains_shell_metacharacters(&editor) { 30 | let mut cmdline = std::ffi::OsString::new(); 31 | cmdline.extend([ 32 | editor.as_ref(), 33 | std::ffi::OsStr::new(" "), 34 | file.as_os_str(), 35 | ]); 36 | 37 | let editor_args = vec![std::ffi::OsString::from("-c"), cmdline]; 38 | (std::path::Path::new("/bin/sh"), editor_args) 39 | } else { 40 | let editor = std::path::Path::new(&editor); 41 | let mut editor_args = vec![]; 42 | 43 | #[allow(clippy::single_match_else)] // more to come 44 | match editor.file_name() { 45 | Some(editor) => match editor.to_str() { 46 | Some("vim" | "nvim") => { 47 | // disable swap files and viminfo for password entry 48 | editor_args.push(std::ffi::OsString::from("-ni")); 49 | editor_args.push(std::ffi::OsString::from("NONE")); 50 | } 51 | _ => { 52 | // other editor support welcomed 53 | } 54 | }, 55 | None => { 56 | return Err(Error::InvalidEditor { 57 | var: var.to_string(), 58 | editor: editor.as_os_str().to_os_string(), 59 | }) 60 | } 61 | } 62 | editor_args.push(file.clone().into_os_string()); 63 | (editor, editor_args) 64 | }; 65 | 66 | let res = std::process::Command::new(cmd).args(&args).status(); 67 | match res { 68 | Ok(res) => { 69 | if !res.success() { 70 | return Err(Error::FailedToRunEditor { 71 | editor: cmd.to_owned(), 72 | args, 73 | res, 74 | }); 75 | } 76 | } 77 | Err(err) => { 78 | return Err(Error::FailedToFindEditor { 79 | editor: cmd.to_owned(), 80 | err, 81 | }) 82 | } 83 | } 84 | 85 | let mut fh = std::fs::File::open(&file).unwrap(); 86 | let mut contents = String::new(); 87 | fh.read_to_string(&mut contents).unwrap(); 88 | drop(fh); 89 | 90 | Ok(contents) 91 | } 92 | 93 | fn contains_shell_metacharacters(cmd: &std::ffi::OsStr) -> bool { 94 | cmd.to_str() 95 | .is_some_and(|s| s.contains(&[' ', '$', '\'', '"'][..])) 96 | } 97 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(thiserror::Error, Debug)] 2 | pub enum Error { 3 | #[error("email address not set")] 4 | ConfigMissingEmail, 5 | 6 | #[error("failed to create block mode decryptor")] 7 | CreateBlockMode { source: aes::cipher::InvalidLength }, 8 | 9 | #[error("failed to create block mode decryptor")] 10 | CreateHmac { source: aes::cipher::InvalidLength }, 11 | 12 | #[error("failed to create directory at {}", .file.display())] 13 | CreateDirectory { 14 | source: std::io::Error, 15 | file: std::path::PathBuf, 16 | }, 17 | 18 | #[error("failed to create reqwest client")] 19 | CreateReqwestClient { source: reqwest::Error }, 20 | 21 | #[error("failed to create sso callback server: {err}")] 22 | CreateSSOCallbackServer { err: std::io::Error }, 23 | 24 | #[error("failed to decrypt")] 25 | Decrypt { source: block_padding::UnpadError }, 26 | 27 | #[error("failed to find free port in {range}")] 28 | FailedToFindFreePort { range: String }, 29 | 30 | #[error("failed to parse pinentry output ({out:?})")] 31 | FailedToParsePinentry { out: String }, 32 | 33 | #[error("failed to process sso callback ({msg})")] 34 | FailedToProcessSSOCallback { msg: String }, 35 | 36 | #[error("failed to open web browser: {err}")] 37 | FailedToOpenWebBrowser { err: std::io::Error }, 38 | 39 | #[error("failed to read from stdin: {err}")] 40 | FailedToReadFromStdin { err: std::io::Error }, 41 | 42 | #[error( 43 | "failed to run editor {}: {err}", 44 | .editor.to_string_lossy(), 45 | )] 46 | FailedToFindEditor { 47 | editor: std::path::PathBuf, 48 | err: std::io::Error, 49 | }, 50 | 51 | #[error( 52 | "failed to run editor {} {}: {res:?}", 53 | .editor.to_string_lossy(), 54 | .args.iter().map(|s| s.to_string_lossy()).collect::>().join(" ") 55 | )] 56 | FailedToRunEditor { 57 | editor: std::path::PathBuf, 58 | args: Vec, 59 | res: std::process::ExitStatus, 60 | }, 61 | 62 | #[error("failed to expand with hkdf")] 63 | HkdfExpand, 64 | 65 | #[error("incorrect api key")] 66 | IncorrectApiKey, 67 | 68 | #[error("{message}")] 69 | IncorrectPassword { message: String }, 70 | 71 | #[error("invalid base64")] 72 | InvalidBase64 { source: base64::DecodeError }, 73 | 74 | #[error("invalid cipherstring: {reason}")] 75 | InvalidCipherString { reason: String }, 76 | 77 | #[error( 78 | "invalid value for ${var}: {}", 79 | .editor.to_string_lossy() 80 | )] 81 | InvalidEditor { 82 | var: String, 83 | editor: std::ffi::OsString, 84 | }, 85 | 86 | #[error("invalid mac")] 87 | InvalidMac, 88 | 89 | #[error("invalid two factor provider type: {ty}")] 90 | InvalidTwoFactorProvider { ty: String }, 91 | 92 | #[error("failed to parse JSON")] 93 | Json { 94 | source: serde_path_to_error::Error, 95 | }, 96 | 97 | #[error("failed to load config from {}", .file.display())] 98 | LoadConfig { 99 | source: std::io::Error, 100 | file: std::path::PathBuf, 101 | }, 102 | 103 | #[error("failed to load config from {}", .file.display())] 104 | LoadConfigAsync { 105 | source: tokio::io::Error, 106 | file: std::path::PathBuf, 107 | }, 108 | 109 | #[error("failed to load config from {}", .file.display())] 110 | LoadConfigJson { 111 | source: serde_json::Error, 112 | file: std::path::PathBuf, 113 | }, 114 | 115 | #[error("failed to load db from {}", .file.display())] 116 | LoadDb { 117 | source: std::io::Error, 118 | file: std::path::PathBuf, 119 | }, 120 | 121 | #[error("failed to load db from {}", .file.display())] 122 | LoadDbAsync { 123 | source: tokio::io::Error, 124 | file: std::path::PathBuf, 125 | }, 126 | 127 | #[error("failed to load db from {}", .file.display())] 128 | LoadDbJson { 129 | source: serde_json::Error, 130 | file: std::path::PathBuf, 131 | }, 132 | 133 | #[error("failed to load device id from {}", .file.display())] 134 | LoadDeviceId { 135 | source: tokio::io::Error, 136 | file: std::path::PathBuf, 137 | }, 138 | 139 | #[error("failed to load client cert from {}", .file.display())] 140 | LoadClientCert { 141 | source: tokio::io::Error, 142 | file: std::path::PathBuf, 143 | }, 144 | 145 | #[error("invalid padding")] 146 | Padding, 147 | 148 | #[error("failed to parse match type {s}")] 149 | ParseMatchType { s: String }, 150 | 151 | #[error("pbkdf2 requires at least 1 iteration (got 0)")] 152 | Pbkdf2ZeroIterations, 153 | 154 | #[error("failed to run pbkdf2")] 155 | Pbkdf2, 156 | 157 | #[error("failed to run argon2")] 158 | Argon2, 159 | 160 | #[error("pinentry cancelled")] 161 | PinentryCancelled, 162 | 163 | #[error("pinentry error: {error}")] 164 | PinentryErrorMessage { error: String }, 165 | 166 | #[error("error reading pinentry output")] 167 | PinentryReadOutput { source: tokio::io::Error }, 168 | 169 | #[error("error waiting for pinentry to exit")] 170 | PinentryWait { source: tokio::io::Error }, 171 | 172 | #[error("This device has not yet been registered with the Bitwarden server. Run `rbw register` first, and then try again.")] 173 | RegistrationRequired, 174 | 175 | #[error("failed to remove db at {}", .file.display())] 176 | RemoveDb { 177 | source: std::io::Error, 178 | file: std::path::PathBuf, 179 | }, 180 | 181 | #[error("api request returned error: {status}")] 182 | RequestFailed { status: u16 }, 183 | 184 | #[error("api request unauthorized")] 185 | RequestUnauthorized, 186 | 187 | #[error("error making api request")] 188 | Reqwest { source: reqwest::Error }, 189 | 190 | #[error("failed to decrypt")] 191 | Rsa { source: rsa::errors::Error }, 192 | 193 | #[error("failed to decrypt")] 194 | RsaPkcs8 { source: rsa::pkcs8::Error }, 195 | 196 | #[error("failed to save config to {}", .file.display())] 197 | SaveConfig { 198 | source: std::io::Error, 199 | file: std::path::PathBuf, 200 | }, 201 | 202 | #[error("failed to save config to {}", .file.display())] 203 | SaveConfigJson { 204 | source: serde_json::Error, 205 | file: std::path::PathBuf, 206 | }, 207 | 208 | #[error("failed to save db to {}", .file.display())] 209 | SaveDb { 210 | source: std::io::Error, 211 | file: std::path::PathBuf, 212 | }, 213 | 214 | #[error("failed to save db to {}", .file.display())] 215 | SaveDbAsync { 216 | source: tokio::io::Error, 217 | file: std::path::PathBuf, 218 | }, 219 | 220 | #[error("failed to save db to {}", .file.display())] 221 | SaveDbJson { 222 | source: serde_json::Error, 223 | file: std::path::PathBuf, 224 | }, 225 | 226 | #[error("error spawning pinentry")] 227 | Spawn { source: tokio::io::Error }, 228 | 229 | #[error("cipherstring type {ty} too old\n\nPlease rotate your account encryption key (https://bitwarden.com/help/article/account-encryption-key/) and try again.")] 230 | TooOldCipherStringType { ty: String }, 231 | 232 | #[error("two factor required")] 233 | TwoFactorRequired { 234 | providers: Vec, 235 | }, 236 | 237 | #[error("unimplemented cipherstring type: {ty}")] 238 | UnimplementedCipherStringType { ty: String }, 239 | 240 | #[error("error writing to pinentry stdin")] 241 | WriteStdin { source: tokio::io::Error }, 242 | 243 | #[error("invalid kdf type: {ty}")] 244 | InvalidKdfType { ty: String }, 245 | } 246 | 247 | pub type Result = std::result::Result; 248 | -------------------------------------------------------------------------------- /src/identity.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use sha1::Digest as _; 4 | 5 | pub struct Identity { 6 | pub email: String, 7 | pub keys: crate::locked::Keys, 8 | pub master_password_hash: crate::locked::PasswordHash, 9 | } 10 | 11 | impl Identity { 12 | pub fn new( 13 | email: &str, 14 | password: &crate::locked::Password, 15 | kdf: crate::api::KdfType, 16 | iterations: u32, 17 | memory: Option, 18 | parallelism: Option, 19 | ) -> Result { 20 | let email = email.trim().to_lowercase(); 21 | 22 | let iterations = std::num::NonZeroU32::new(iterations) 23 | .ok_or(Error::Pbkdf2ZeroIterations)?; 24 | 25 | let mut keys = crate::locked::Vec::new(); 26 | keys.extend(std::iter::repeat_n(0, 64)); 27 | 28 | let enc_key = &mut keys.data_mut()[0..32]; 29 | 30 | match kdf { 31 | crate::api::KdfType::Pbkdf2 => { 32 | pbkdf2::pbkdf2::>( 33 | password.password(), 34 | email.as_bytes(), 35 | iterations.get(), 36 | enc_key, 37 | ) 38 | .map_err(|_| Error::Pbkdf2)?; 39 | } 40 | 41 | crate::api::KdfType::Argon2id => { 42 | let mut hasher = sha2::Sha256::new(); 43 | hasher.update(email.as_bytes()); 44 | let salt = hasher.finalize(); 45 | 46 | let argon2_config = argon2::Argon2::new( 47 | argon2::Algorithm::Argon2id, 48 | argon2::Version::V0x13, 49 | argon2::Params::new( 50 | memory.unwrap() * 1024, 51 | iterations.get(), 52 | parallelism.unwrap(), 53 | Some(32), 54 | ) 55 | .unwrap(), 56 | ); 57 | argon2::Argon2::hash_password_into( 58 | &argon2_config, 59 | password.password(), 60 | &salt, 61 | enc_key, 62 | ) 63 | .map_err(|_| Error::Argon2)?; 64 | } 65 | } 66 | 67 | let mut hash = crate::locked::Vec::new(); 68 | hash.extend(std::iter::repeat_n(0, 32)); 69 | pbkdf2::pbkdf2::>( 70 | enc_key, 71 | password.password(), 72 | 1, 73 | hash.data_mut(), 74 | ) 75 | .map_err(|_| Error::Pbkdf2)?; 76 | 77 | let hkdf = hkdf::Hkdf::::from_prk(enc_key) 78 | .map_err(|_| Error::HkdfExpand)?; 79 | hkdf.expand(b"enc", enc_key) 80 | .map_err(|_| Error::HkdfExpand)?; 81 | let mac_key = &mut keys.data_mut()[32..64]; 82 | hkdf.expand(b"mac", mac_key) 83 | .map_err(|_| Error::HkdfExpand)?; 84 | 85 | let keys = crate::locked::Keys::new(keys); 86 | let master_password_hash = crate::locked::PasswordHash::new(hash); 87 | 88 | Ok(Self { 89 | email: email.to_string(), 90 | keys, 91 | master_password_hash, 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/json.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub trait DeserializeJsonWithPath { 4 | fn json_with_path(self) -> Result; 5 | } 6 | 7 | impl DeserializeJsonWithPath for String { 8 | fn json_with_path(self) -> Result { 9 | let jd = &mut serde_json::Deserializer::from_str(&self); 10 | serde_path_to_error::deserialize(jd) 11 | .map_err(|source| Error::Json { source }) 12 | } 13 | } 14 | 15 | impl DeserializeJsonWithPath for reqwest::blocking::Response { 16 | fn json_with_path(self) -> Result { 17 | let bytes = 18 | self.bytes().map_err(|source| Error::Reqwest { source })?; 19 | let jd = &mut serde_json::Deserializer::from_slice(&bytes); 20 | serde_path_to_error::deserialize(jd) 21 | .map_err(|source| Error::Json { source }) 22 | } 23 | } 24 | 25 | pub trait DeserializeJsonWithPathAsync { 26 | #[allow(async_fn_in_trait)] 27 | async fn json_with_path( 28 | self, 29 | ) -> Result; 30 | } 31 | 32 | impl DeserializeJsonWithPathAsync for reqwest::Response { 33 | async fn json_with_path( 34 | self, 35 | ) -> Result { 36 | let bytes = self 37 | .bytes() 38 | .await 39 | .map_err(|source| Error::Reqwest { source })?; 40 | let jd = &mut serde_json::Deserializer::from_slice(&bytes); 41 | serde_path_to_error::deserialize(jd) 42 | .map_err(|source| Error::Json { source }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod actions; 2 | pub mod api; 3 | pub mod base64; 4 | pub mod cipherstring; 5 | pub mod config; 6 | pub mod db; 7 | pub mod dirs; 8 | pub mod edit; 9 | pub mod error; 10 | pub mod identity; 11 | pub mod json; 12 | pub mod locked; 13 | pub mod pinentry; 14 | mod prelude; 15 | pub mod protocol; 16 | pub mod pwgen; 17 | pub mod wordlist; 18 | -------------------------------------------------------------------------------- /src/locked.rs: -------------------------------------------------------------------------------- 1 | use zeroize::Zeroize as _; 2 | 3 | const LEN: usize = 4096; 4 | 5 | static REGION_LOCK_WORKS: std::sync::OnceLock = 6 | std::sync::OnceLock::new(); 7 | 8 | pub struct Vec { 9 | data: Box>, 10 | _lock: Option, 11 | } 12 | 13 | impl Default for Vec { 14 | fn default() -> Self { 15 | let data = Box::new(arrayvec::ArrayVec::<_, LEN>::new()); 16 | let lock = match REGION_LOCK_WORKS.get() { 17 | Some(true) => { 18 | Some(region::lock(data.as_ptr(), data.capacity()).unwrap()) 19 | } 20 | Some(false) => None, 21 | None => match region::lock(data.as_ptr(), data.capacity()) { 22 | Ok(lock) => { 23 | let _ = REGION_LOCK_WORKS.set(true); 24 | Some(lock) 25 | } 26 | Err(e) => { 27 | if REGION_LOCK_WORKS.set(false).is_ok() { 28 | eprintln!("failed to lock memory region: {e}"); 29 | } 30 | None 31 | } 32 | }, 33 | }; 34 | Self { data, _lock: lock } 35 | } 36 | } 37 | 38 | impl Vec { 39 | pub fn new() -> Self { 40 | Self::default() 41 | } 42 | 43 | pub fn data(&self) -> &[u8] { 44 | self.data.as_slice() 45 | } 46 | 47 | pub fn data_mut(&mut self) -> &mut [u8] { 48 | self.data.as_mut_slice() 49 | } 50 | 51 | pub fn zero(&mut self) { 52 | self.truncate(0); 53 | self.data.extend(std::iter::repeat_n(0, LEN)); 54 | } 55 | 56 | pub fn extend(&mut self, it: impl Iterator) { 57 | self.data.extend(it); 58 | } 59 | 60 | pub fn truncate(&mut self, len: usize) { 61 | self.data.truncate(len); 62 | } 63 | } 64 | 65 | impl Drop for Vec { 66 | fn drop(&mut self) { 67 | self.zero(); 68 | self.data.as_mut().zeroize(); 69 | } 70 | } 71 | 72 | impl Clone for Vec { 73 | fn clone(&self) -> Self { 74 | let mut new_vec = Self::new(); 75 | new_vec.extend(self.data().iter().copied()); 76 | new_vec 77 | } 78 | } 79 | 80 | #[derive(Clone)] 81 | pub struct Password { 82 | password: Vec, 83 | } 84 | 85 | impl Password { 86 | pub fn new(password: Vec) -> Self { 87 | Self { password } 88 | } 89 | 90 | pub fn password(&self) -> &[u8] { 91 | self.password.data() 92 | } 93 | } 94 | 95 | #[derive(Clone)] 96 | pub struct Keys { 97 | keys: Vec, 98 | } 99 | 100 | impl Keys { 101 | pub fn new(keys: Vec) -> Self { 102 | Self { keys } 103 | } 104 | 105 | pub fn enc_key(&self) -> &[u8] { 106 | &self.keys.data()[0..32] 107 | } 108 | 109 | pub fn mac_key(&self) -> &[u8] { 110 | &self.keys.data()[32..64] 111 | } 112 | } 113 | 114 | #[derive(Clone)] 115 | pub struct PasswordHash { 116 | hash: Vec, 117 | } 118 | 119 | impl PasswordHash { 120 | pub fn new(hash: Vec) -> Self { 121 | Self { hash } 122 | } 123 | 124 | pub fn hash(&self) -> &[u8] { 125 | self.hash.data() 126 | } 127 | } 128 | 129 | #[derive(Clone)] 130 | pub struct PrivateKey { 131 | private_key: Vec, 132 | } 133 | 134 | impl PrivateKey { 135 | pub fn new(private_key: Vec) -> Self { 136 | Self { private_key } 137 | } 138 | 139 | pub fn private_key(&self) -> &[u8] { 140 | self.private_key.data() 141 | } 142 | } 143 | 144 | #[derive(Clone)] 145 | pub struct ApiKey { 146 | client_id: Password, 147 | client_secret: Password, 148 | } 149 | 150 | impl ApiKey { 151 | pub fn new(client_id: Password, client_secret: Password) -> Self { 152 | Self { 153 | client_id, 154 | client_secret, 155 | } 156 | } 157 | 158 | pub fn client_id(&self) -> &[u8] { 159 | self.client_id.password() 160 | } 161 | 162 | pub fn client_secret(&self) -> &[u8] { 163 | self.client_secret.password() 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/pinentry.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use std::convert::TryFrom as _; 4 | 5 | use tokio::io::AsyncWriteExt as _; 6 | 7 | pub async fn getpin( 8 | pinentry: &str, 9 | prompt: &str, 10 | desc: &str, 11 | err: Option<&str>, 12 | environment: &crate::protocol::Environment, 13 | grab: bool, 14 | ) -> Result { 15 | let mut opts = tokio::process::Command::new(pinentry); 16 | opts.stdin(std::process::Stdio::piped()) 17 | .stdout(std::process::Stdio::piped()); 18 | let mut args = vec!["--timeout".into(), "0".into()]; 19 | if let Some(tty) = environment.tty() { 20 | args.extend(["--ttyname".into(), tty.into()]); 21 | } 22 | 23 | let env_vars = environment.env_vars(); 24 | // Not all pinentry appear to respect the --display flag, so we also keep the environment 25 | // variable. 26 | if let Some(display) = 27 | env_vars.get(std::ffi::OsString::from("DISPLAY").as_os_str()) 28 | { 29 | args.extend(["--display".into(), display.clone()]); 30 | } 31 | if !grab { 32 | args.push("--no-global-grab".into()); 33 | } 34 | opts.args(args); 35 | 36 | for env_var in &*crate::protocol::ENVIRONMENT_VARIABLES_OS { 37 | if let Some(val) = env_vars.get(env_var) { 38 | opts.env(env_var, val); 39 | } else { 40 | opts.env_remove(env_var); 41 | } 42 | } 43 | opts.envs(env_vars); 44 | 45 | let mut child = opts.spawn().map_err(|source| Error::Spawn { source })?; 46 | // unwrap is safe because we specified stdin as piped in the command opts 47 | // above 48 | let mut stdin = child.stdin.take().unwrap(); 49 | 50 | let mut ncommands = 1; 51 | stdin 52 | .write_all(b"SETTITLE rbw\n") 53 | .await 54 | .map_err(|source| Error::WriteStdin { source })?; 55 | ncommands += 1; 56 | stdin 57 | .write_all(format!("SETPROMPT {prompt}\n").as_bytes()) 58 | .await 59 | .map_err(|source| Error::WriteStdin { source })?; 60 | ncommands += 1; 61 | stdin 62 | .write_all(format!("SETDESC {desc}\n").as_bytes()) 63 | .await 64 | .map_err(|source| Error::WriteStdin { source })?; 65 | ncommands += 1; 66 | if let Some(err) = err { 67 | stdin 68 | .write_all(format!("SETERROR {err}\n").as_bytes()) 69 | .await 70 | .map_err(|source| Error::WriteStdin { source })?; 71 | ncommands += 1; 72 | } 73 | stdin 74 | .write_all(b"GETPIN\n") 75 | .await 76 | .map_err(|source| Error::WriteStdin { source })?; 77 | ncommands += 1; 78 | drop(stdin); 79 | 80 | let mut buf = crate::locked::Vec::new(); 81 | buf.zero(); 82 | // unwrap is safe because we specified stdout as piped in the command opts 83 | // above 84 | let len = read_password( 85 | ncommands, 86 | buf.data_mut(), 87 | child.stdout.as_mut().unwrap(), 88 | ) 89 | .await?; 90 | buf.truncate(len); 91 | 92 | child 93 | .wait() 94 | .await 95 | .map_err(|source| Error::PinentryWait { source })?; 96 | 97 | Ok(crate::locked::Password::new(buf)) 98 | } 99 | 100 | async fn read_password( 101 | mut ncommands: u8, 102 | data: &mut [u8], 103 | mut r: R, 104 | ) -> Result 105 | where 106 | R: tokio::io::AsyncRead + tokio::io::AsyncReadExt + Unpin + Send, 107 | { 108 | let mut len = 0; 109 | loop { 110 | let nl = data.iter().take(len).position(|c| *c == b'\n'); 111 | if let Some(nl) = nl { 112 | if data.starts_with(b"OK") { 113 | if ncommands == 1 { 114 | len = 0; 115 | break; 116 | } 117 | data.copy_within((nl + 1).., 0); 118 | len -= nl + 1; 119 | ncommands -= 1; 120 | } else if data.starts_with(b"D ") { 121 | data.copy_within(2..nl, 0); 122 | len = nl - 2; 123 | break; 124 | } else if data.starts_with(b"ERR ") { 125 | let line: Vec = data.iter().take(nl).copied().collect(); 126 | let line = String::from_utf8(line).unwrap(); 127 | let mut split = line.splitn(3, ' '); 128 | let _ = split.next(); // ERR 129 | let code = split.next(); 130 | match code { 131 | Some("83886179") => { 132 | return Err(Error::PinentryCancelled); 133 | } 134 | Some(code) => { 135 | if let Some(error) = split.next() { 136 | return Err(Error::PinentryErrorMessage { 137 | error: error.to_string(), 138 | }); 139 | } 140 | return Err(Error::PinentryErrorMessage { 141 | error: format!("unknown error ({code})"), 142 | }); 143 | } 144 | None => { 145 | return Err(Error::PinentryErrorMessage { 146 | error: "unknown error".to_string(), 147 | }); 148 | } 149 | } 150 | } else { 151 | return Err(Error::FailedToParsePinentry { 152 | out: String::from_utf8_lossy(data).to_string(), 153 | }); 154 | } 155 | } else { 156 | let bytes = r 157 | .read(&mut data[len..]) 158 | .await 159 | .map_err(|source| Error::PinentryReadOutput { source })?; 160 | if bytes == 0 { 161 | return Err(Error::PinentryReadOutput { 162 | source: std::io::Error::new( 163 | std::io::ErrorKind::UnexpectedEof, 164 | "unexpected EOF", 165 | ), 166 | }); 167 | } 168 | len += bytes; 169 | } 170 | } 171 | 172 | len = percent_decode(&mut data[..len]); 173 | 174 | Ok(len) 175 | } 176 | 177 | // not using the percent-encoding crate because it doesn't provide a way to do 178 | // this in-place, and we want the password to always live within the locked 179 | // vec. should really move something like this into the percent-encoding crate 180 | // at some point. 181 | fn percent_decode(buf: &mut [u8]) -> usize { 182 | let mut read_idx = 0; 183 | let mut write_idx = 0; 184 | let len = buf.len(); 185 | 186 | while read_idx < len { 187 | let mut c = buf[read_idx]; 188 | 189 | if c == b'%' && read_idx + 2 < len { 190 | if let Some(h) = char::from(buf[read_idx + 1]).to_digit(16) { 191 | if let Some(l) = char::from(buf[read_idx + 2]).to_digit(16) { 192 | // h and l were parsed from a single hex digit, so they 193 | // must be in the range 0-15, so these unwraps are safe 194 | c = u8::try_from(h).unwrap() * 0x10 195 | + u8::try_from(l).unwrap(); 196 | read_idx += 2; 197 | } 198 | } 199 | } 200 | 201 | buf[write_idx] = c; 202 | read_idx += 1; 203 | write_idx += 1; 204 | } 205 | 206 | write_idx 207 | } 208 | 209 | #[test] 210 | fn test_read_password() { 211 | let good_inputs = &[ 212 | (0, &b"D super secret password\n"[..]), 213 | (4, &b"OK\nOK\nOK\nD super secret password\nOK\n"[..]), 214 | (12, &b"OK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nD super secret password\nOK\n"[..]), 215 | (24, &b"OK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nD super secret password\nOK\n"[..]), 216 | ]; 217 | for (ncommands, input) in good_inputs { 218 | let mut buf = [0; 64]; 219 | tokio::runtime::Runtime::new().unwrap().block_on(async { 220 | let len = read_password(*ncommands, &mut buf, &input[..]) 221 | .await 222 | .unwrap(); 223 | assert_eq!(&buf[0..len], b"super secret password"); 224 | }); 225 | } 226 | 227 | let match_inputs = &[ 228 | (&b"OK\nOK\nOK\nOK\n"[..], &b""[..]), 229 | (&b"D foo%25bar\n"[..], &b"foo%bar"[..]), 230 | (&b"D foo%0abar\n"[..], &b"foo\nbar"[..]), 231 | (&b"D foo%0Abar\n"[..], &b"foo\nbar"[..]), 232 | (&b"D foo%0Gbar\n"[..], &b"foo%0Gbar"[..]), 233 | (&b"D foo%0\n"[..], &b"foo%0"[..]), 234 | (&b"D foo%\n"[..], &b"foo%"[..]), 235 | (&b"D %25foo\n"[..], &b"%foo"[..]), 236 | (&b"D %25\n"[..], &b"%"[..]), 237 | ]; 238 | 239 | for (input, output) in match_inputs { 240 | let mut buf = [0; 64]; 241 | tokio::runtime::Runtime::new().unwrap().block_on(async { 242 | let len = read_password(4, &mut buf, &input[..]).await.unwrap(); 243 | assert_eq!(&buf[0..len], &output[..]); 244 | }); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/prelude.rs: -------------------------------------------------------------------------------- 1 | pub use crate::error::{Error, Result}; 2 | -------------------------------------------------------------------------------- /src/protocol.rs: -------------------------------------------------------------------------------- 1 | use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _}; 2 | 3 | // eventually it would be nice to make this a const function so that we could 4 | // just get the version from a variable directly, but this is fine for now 5 | pub fn version() -> u32 { 6 | let major = env!("CARGO_PKG_VERSION_MAJOR"); 7 | let minor = env!("CARGO_PKG_VERSION_MINOR"); 8 | let patch = env!("CARGO_PKG_VERSION_PATCH"); 9 | 10 | major.parse::().unwrap() * 1_000_000 11 | + minor.parse::().unwrap() * 1_000 12 | + patch.parse::().unwrap() 13 | } 14 | 15 | #[derive(serde::Serialize, serde::Deserialize, Debug)] 16 | pub struct Request { 17 | tty: Option, 18 | environment: Option, 19 | pub action: Action, 20 | } 21 | 22 | impl Request { 23 | pub fn new(environment: Environment, action: Action) -> Self { 24 | Self { 25 | tty: None, 26 | environment: Some(environment), 27 | action, 28 | } 29 | } 30 | 31 | pub fn environment(self) -> Environment { 32 | self.environment.unwrap_or_else(|| Environment { 33 | tty: self.tty.map(|tty| SerializableOsString(tty.into())), 34 | env_vars: vec![], 35 | }) 36 | } 37 | } 38 | 39 | // Taken from https://github.com/gpg/gnupg/blob/36dbca3e6944d13e75e96eace634e58a7d7e201d/common/session-env.c#L62-L91 40 | pub const ENVIRONMENT_VARIABLES: &[&str] = &[ 41 | // Used to set ttytype 42 | "TERM", 43 | // The X display 44 | "DISPLAY", 45 | // Xlib Authentication 46 | "XAUTHORITY", 47 | // Used by Xlib to select X input modules (e.g. "@im=SCIM") 48 | "XMODIFIERS", 49 | // For the Wayland display engine. 50 | "WAYLAND_DISPLAY", 51 | // Used by Qt and other non-GTK toolkits to check for X11 or Wayland 52 | "XDG_SESSION_TYPE", 53 | // Used by Qt to explicitly request X11 or Wayland; in particular, needed to 54 | // make Qt use Wayland on GNOME 55 | "QT_QPA_PLATFORM", 56 | // Used by GTK to select GTK input modules (e.g. "scim-bridge") 57 | "GTK_IM_MODULE", 58 | // Used by GNOME 3 to talk to gcr over dbus 59 | "DBUS_SESSION_BUS_ADDRESS", 60 | // Used by Qt to select Qt input modules (e.g. "xim") 61 | "QT_IM_MODULE", 62 | // Used for communication with non-standard Pinentries 63 | "PINENTRY_USER_DATA", 64 | // Used to pass window information 65 | "PINENTRY_GEOM_HINT", 66 | ]; 67 | 68 | pub static ENVIRONMENT_VARIABLES_OS: std::sync::LazyLock< 69 | Vec, 70 | > = std::sync::LazyLock::new(|| { 71 | ENVIRONMENT_VARIABLES 72 | .iter() 73 | .map(std::ffi::OsString::from) 74 | .collect() 75 | }); 76 | 77 | #[derive(Hash, PartialEq, Eq, Debug)] 78 | struct SerializableOsString(std::ffi::OsString); 79 | 80 | impl serde::Serialize for SerializableOsString { 81 | fn serialize(&self, serializer: S) -> Result 82 | where 83 | S: serde::Serializer, 84 | { 85 | serializer.serialize_bytes(self.0.as_bytes()) 86 | } 87 | } 88 | 89 | impl<'de> serde::Deserialize<'de> for SerializableOsString { 90 | fn deserialize(deserializer: D) -> Result 91 | where 92 | D: serde::Deserializer<'de>, 93 | { 94 | struct Visitor; 95 | 96 | impl<'de> serde::de::Visitor<'de> for Visitor { 97 | type Value = SerializableOsString; 98 | 99 | fn expecting( 100 | &self, 101 | formatter: &mut std::fmt::Formatter, 102 | ) -> std::fmt::Result { 103 | formatter.write_str("os string") 104 | } 105 | 106 | fn visit_seq( 107 | self, 108 | mut access: S, 109 | ) -> Result 110 | where 111 | S: serde::de::SeqAccess<'de>, 112 | { 113 | let mut bytes = 114 | Vec::with_capacity(access.size_hint().unwrap_or(0)); 115 | while let Some(b) = access.next_element()? { 116 | bytes.push(b); 117 | } 118 | Ok(SerializableOsString(std::ffi::OsString::from_vec(bytes))) 119 | } 120 | } 121 | 122 | deserializer.deserialize_bytes(Visitor) 123 | } 124 | } 125 | 126 | #[derive(serde::Serialize, serde::Deserialize, Debug)] 127 | pub struct Environment { 128 | tty: Option, 129 | env_vars: Vec<(SerializableOsString, SerializableOsString)>, 130 | } 131 | 132 | impl Environment { 133 | pub fn new( 134 | tty: Option, 135 | env_vars: Vec<(std::ffi::OsString, std::ffi::OsString)>, 136 | ) -> Self { 137 | Self { 138 | tty: tty.map(SerializableOsString), 139 | env_vars: env_vars 140 | .into_iter() 141 | .map(|(k, v)| { 142 | (SerializableOsString(k), SerializableOsString(v)) 143 | }) 144 | .collect(), 145 | } 146 | } 147 | 148 | pub fn tty(&self) -> Option<&std::ffi::OsStr> { 149 | self.tty.as_ref().map(|tty| tty.0.as_os_str()) 150 | } 151 | 152 | pub fn env_vars( 153 | &self, 154 | ) -> std::collections::HashMap 155 | { 156 | self.env_vars 157 | .iter() 158 | .map(|(var, val)| (var.0.clone(), val.0.clone())) 159 | .filter(|(var, _)| (*ENVIRONMENT_VARIABLES_OS).contains(var)) 160 | .collect() 161 | } 162 | } 163 | 164 | #[derive(serde::Serialize, serde::Deserialize, Debug)] 165 | #[serde(tag = "type")] 166 | pub enum Action { 167 | Login, 168 | Register, 169 | Unlock, 170 | CheckLock, 171 | Lock, 172 | Sync, 173 | Decrypt { 174 | cipherstring: String, 175 | entry_key: Option, 176 | org_id: Option, 177 | }, 178 | Encrypt { 179 | plaintext: String, 180 | org_id: Option, 181 | }, 182 | ClipboardStore { 183 | text: String, 184 | }, 185 | Quit, 186 | Version, 187 | } 188 | 189 | #[derive(serde::Serialize, serde::Deserialize, Debug)] 190 | #[serde(tag = "type")] 191 | pub enum Response { 192 | Ack, 193 | Error { error: String }, 194 | Decrypt { plaintext: String }, 195 | Encrypt { cipherstring: String }, 196 | Version { version: u32 }, 197 | } 198 | -------------------------------------------------------------------------------- /src/pwgen.rs: -------------------------------------------------------------------------------- 1 | use rand::seq::SliceRandom as _; 2 | 3 | const SYMBOLS: &[u8] = b"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; 4 | const NUMBERS: &[u8] = b"0123456789"; 5 | const LETTERS: &[u8] = 6 | b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; 7 | const NONCONFUSABLES: &[u8] = b"34678abcdefhjkmnpqrtuwxy"; 8 | 9 | #[derive(Debug, Eq, PartialEq, Copy, Clone)] 10 | pub enum Type { 11 | AllChars, 12 | NoSymbols, 13 | Numbers, 14 | NonConfusables, 15 | Diceware, 16 | } 17 | 18 | pub fn pwgen(ty: Type, len: usize) -> String { 19 | let mut rng = rand::thread_rng(); 20 | 21 | let alphabet = match ty { 22 | Type::AllChars => { 23 | let mut v = vec![]; 24 | v.extend(SYMBOLS.iter().copied()); 25 | v.extend(NUMBERS.iter().copied()); 26 | v.extend(LETTERS.iter().copied()); 27 | v 28 | } 29 | Type::NoSymbols => { 30 | let mut v = vec![]; 31 | v.extend(NUMBERS.iter().copied()); 32 | v.extend(LETTERS.iter().copied()); 33 | v 34 | } 35 | Type::Numbers => { 36 | let mut v = vec![]; 37 | v.extend(NUMBERS.iter().copied()); 38 | v 39 | } 40 | Type::NonConfusables => { 41 | let mut v = vec![]; 42 | v.extend(NONCONFUSABLES.iter().copied()); 43 | v 44 | } 45 | Type::Diceware => { 46 | return diceware(&mut rng, len); 47 | } 48 | }; 49 | 50 | let mut pass = vec![]; 51 | pass.extend( 52 | std::iter::repeat_with(|| alphabet.choose(&mut rng).unwrap()) 53 | .take(len), 54 | ); 55 | // unwrap is safe because the method of generating passwords guarantees 56 | // valid utf8 57 | String::from_utf8(pass).unwrap() 58 | } 59 | 60 | fn diceware(rng: &mut impl rand::RngCore, len: usize) -> String { 61 | let mut words = vec![]; 62 | for _ in 0..len { 63 | // unwrap is safe because choose only returns None for an empty slice 64 | words.push(*crate::wordlist::EFF_LONG.choose(rng).unwrap()); 65 | } 66 | words.join(" ") 67 | } 68 | 69 | #[cfg(test)] 70 | mod test { 71 | use super::*; 72 | 73 | #[test] 74 | fn test_pwgen() { 75 | let pw = pwgen(Type::AllChars, 50); 76 | assert_eq!(pw.len(), 50); 77 | // technically this could fail, but the chances are incredibly low 78 | // (around 0.000009%) 79 | assert_duplicates(&pw); 80 | 81 | let pw = pwgen(Type::AllChars, 100); 82 | assert_eq!(pw.len(), 100); 83 | assert_duplicates(&pw); 84 | 85 | let pw = pwgen(Type::NoSymbols, 100); 86 | assert_eq!(pw.len(), 100); 87 | assert_duplicates(&pw); 88 | 89 | let pw = pwgen(Type::Numbers, 100); 90 | assert_eq!(pw.len(), 100); 91 | assert_duplicates(&pw); 92 | 93 | let pw = pwgen(Type::NonConfusables, 100); 94 | assert_eq!(pw.len(), 100); 95 | assert_duplicates(&pw); 96 | } 97 | 98 | #[track_caller] 99 | fn assert_duplicates(s: &str) { 100 | let mut set = std::collections::HashSet::new(); 101 | for c in s.chars() { 102 | set.insert(c); 103 | } 104 | assert!(set.len() < s.len()); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tools/generate_wordlist: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | echo "pub const EFF_LONG: &[&str] = &[" 5 | curl -s https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt | sed 's/\(.*\)\t\(.*\)/ "\2",/' 6 | echo "];" 7 | --------------------------------------------------------------------------------