├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── publish.yml │ ├── release.yml │ └── zizmor.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build.rs ├── contrib ├── README.md ├── ext-cmds │ ├── kbs2-audit-pass │ │ ├── README.md │ │ └── kbs2-audit-pass │ ├── kbs2-choose-pass │ │ ├── README.md │ │ ├── kbs2-choose-pass │ │ ├── kbs2-choose-pass.karabiner.json │ │ └── kbs2-choose-pass.workflow │ │ │ └── Contents │ │ │ ├── Info.plist │ │ │ └── document.wflow │ ├── kbs2-dmenu-pass │ │ ├── README.md │ │ └── kbs2-dmenu-pass │ ├── kbs2-git-ssh-signing │ │ ├── README.md │ │ └── kbs2-git-ssh-signing │ ├── kbs2-gpg-add │ │ ├── README.md │ │ └── kbs2-gpg-add │ ├── kbs2-kbsecret-env-import │ │ ├── README.md │ │ └── kbs2-kbsecret-env-import │ ├── kbs2-kbsecret-login-import │ │ ├── README.md │ │ └── kbs2-kbsecret-login-import │ ├── kbs2-qr │ │ ├── README.md │ │ └── kbs2-qr │ ├── kbs2-snip │ │ ├── README.md │ │ └── kbs2-snip │ ├── kbs2-ssh-add │ │ ├── README.md │ │ └── kbs2-ssh-add │ └── kbs2-yad-login │ │ ├── README.md │ │ └── kbs2-yad-login └── hooks │ ├── error-hook-notify │ ├── README.md │ └── error-hook-notify │ ├── pass-clear-notify │ ├── README.md │ └── pass-clear-notify │ └── push-repo │ ├── README.md │ └── push-repo ├── src ├── kbs2 │ ├── agent.rs │ ├── backend.rs │ ├── command.rs │ ├── config.rs │ ├── generator.rs │ ├── input.rs │ ├── mod.rs │ ├── record.rs │ ├── session.rs │ └── util.rs └── main.rs └── tests ├── common └── mod.rs ├── test_kbs2.rs ├── test_kbs2_init.rs ├── test_kbs2_new.rs ├── test_kbs2_rename.rs └── test_kbs2_rm.rs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | groups: 8 | rust: 9 | patterns: 10 | - "*" 11 | 12 | - package-ecosystem: github-actions 13 | directory: / 14 | schedule: 15 | interval: weekly 16 | groups: 17 | github-actions: 18 | patterns: 19 | - "*" 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 16 | with: 17 | persist-credentials: false 18 | 19 | - name: Format 20 | run: cargo fmt && git diff --exit-code 21 | 22 | - name: Lint 23 | run: | 24 | rustup update 25 | rustup component add clippy 26 | cargo clippy -- -D warnings 27 | 28 | test: 29 | strategy: 30 | matrix: 31 | platform: ["ubuntu-latest", "macos-latest"] 32 | runs-on: ${{ matrix.platform }} 33 | steps: 34 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 35 | with: 36 | persist-credentials: false 37 | 38 | - name: deps 39 | if: matrix.platform == 'ubuntu-latest' 40 | run: | 41 | sudo apt install -y libxcb-shape0-dev libxcb-xfixes0-dev 42 | 43 | - name: Build 44 | run: cargo build 45 | 46 | - name: Test 47 | run: cargo test 48 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | crate: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 15 | with: 16 | persist-credentials: false 17 | 18 | - name: deps 19 | run: | 20 | sudo apt install -y libxcb-shape0-dev libxcb-xfixes0-dev 21 | 22 | - name: publish 23 | run: cargo publish 24 | env: 25 | CARGO_REGISTRY_TOKEN: "${{ secrets.CRATES_IO_TOKEN }}" 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' 5 | 6 | name: release 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: create release 15 | id: create_release 16 | uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2 17 | env: 18 | # NOTE(ww): GitHub actions cannot trigger other GitHub actions by default, 19 | # but we need that behavior to trigger the 'publish' workflow. 20 | # The workaround is to use a PAT instead of the default GITHUB_TOKEN. 21 | GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} 22 | with: 23 | prerelease: ${{ contains(github.ref, 'pre') || contains(github.ref, 'rc') }} 24 | -------------------------------------------------------------------------------- /.github/workflows/zizmor.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Actions Security Analysis with zizmor 🌈 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["**"] 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | zizmor: 13 | name: zizmor latest via PyPI 14 | runs-on: ubuntu-latest 15 | permissions: 16 | security-events: write 17 | contents: read # only needed for private repos 18 | actions: read # only needed for private repos 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | with: 23 | persist-credentials: false 24 | 25 | - name: Install the latest version of uv 26 | uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca # v6.0.1 27 | 28 | - name: Run zizmor 🌈 29 | run: uvx zizmor --format=sarif . > results.sarif 30 | env: 31 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Upload SARIF file 34 | uses: github/codeql-action/upload-sarif@v3 35 | with: 36 | sarif_file: results.sarif 37 | category: zizmor 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to `kbs2` will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 5 | 6 | All versions prior to 0.2.1 are untracked. 7 | 8 | 9 | 10 | ## [Unreleased] - ReleaseDate 11 | 12 | ## [0.7.2] - 2023-03-05 13 | 14 | ### Added 15 | 16 | * CLI: The `kbs2 rename` command has been added 17 | ([#518](https://github.com/woodruffw/kbs2/pull/518)) 18 | 19 | ## [0.7.1] - 2023-02-24 20 | 21 | ### Fixed 22 | 23 | * CLI: A regression in subcommand handling was fixed 24 | ([#514](https://github.com/woodruffw/kbs2/pull/514)) 25 | 26 | ## [0.7.0] - 2023-02-24 27 | 28 | ### Added 29 | 30 | * Contrib: Added the `kbs2-git-ssh-signing` script, which helps 31 | integrate SSH keys stored in `kbs2` into `git`'s SSH signing workflow 32 | ([#491](https://github.com/woodruffw/kbs2/pull/491)) 33 | 34 | ### Removed 35 | 36 | * Config: Support for the deprecated `kbs2.conf` config file has been fully 37 | removed ([#418](https://github.com/woodruffw/kbs2/pull/418)) 38 | 39 | * Config/CLI: Support for deprecated "legacy" secret generators has been fully 40 | removed ([#419](https://github.com/woodruffw/kbs2/pull/419)) 41 | 42 | * Config: Support for `commands.pass.x11-clipboard` has been removed 43 | ([#460](https://github.com/woodruffw/kbs2/pull/460)) 44 | 45 | * Config: Support for "external" generators has been removed 46 | ([#513](https://github.com/woodruffw/kbs2/pull/513)) 47 | 48 | ### Fixed 49 | 50 | * CLI: `kbs2 edit` now allows for the use of command line text editors 51 | ([#435](https://github.com/woodruffw/kbs2/pull/435)) 52 | 53 | ## [0.6.0] - 2022-06-28 54 | 55 | ### Added 56 | 57 | * Contrib: The `kbs2-dmenu-pass` command now reads the 58 | `commands.ext.dmenu-pass.chooser` setting for a user-specified `dmenu` 59 | replacement. `dmenu` remains the default 60 | ([#313](https://github.com/woodruffw/kbs2/pull/313)) 61 | 62 | * Config: The `commands.new.default-username` field allows the user to specify 63 | a default username when creating logins with `kbs2 new` 64 | ([#307](https://github.com/woodruffw/kbs2/pull/307)) 65 | 66 | ### Changed 67 | 68 | * CLI: The CLI now uses [inquire](https://github.com/mikaelmello/inquire) for 69 | all prompts and dialogs. All functionality should be the same, but the prompts 70 | themselves have changed ([#306](https://github.com/woodruffw/kbs2/pull/306)) 71 | 72 | * Config: `kbs2` now respects XDG for loading the config and store directories. 73 | Most users should not observe a change, but some may have to migrate their 74 | configuration and/or store directories to the directories listed in their 75 | `$XDG_CONFIG_HOME` and `$XDG_DATA_HOME` directories for config and store data, 76 | respectively ([#315](https://github.com/woodruffw/kbs2/pull/315)) 77 | 78 | * Contrib: `kbs2 snip` now checks `commands.ext.snip.chooser` instead of 79 | `commands.ext.snip.matcher` ([#329](https://github.com/woodruffw/kbs2/pull/329)) 80 | 81 | * Contrib: `kbs2 yad-login` now supports overwriting preexisting records 82 | 83 | ### Removed 84 | 85 | * CLI: The `-g`, `--generate` flag has been removed from `kbs2 new`. Generation 86 | is now done "intelligently" with the behavior that was previously controlled 87 | by the `commands.new.generate-on-empty` configuration option 88 | ([#306](https://github.com/woodruffw/kbs2/pull/306)) 89 | 90 | * Config: The `commands.new.generate-on-empty` option has been removed, as its 91 | behavior is now the default ([#306](https://github.com/woodruffw/kbs2/pull/306)) 92 | 93 | ## [0.5.1] - 2022-02-15 94 | 95 | ### Added 96 | 97 | * CLI: The `kbs2 config` and `kbs2 config dump` subcommands have been added, 98 | allowing for easy access to the active configuration state in JSON 99 | ([#304](https://github.com/woodruffw/kbs2/pull/304)) 100 | 101 | ### Changed 102 | 103 | * Contrib: All contrib scripts have been refactored to take advantage of 104 | `kbs2 config dump` ([#304](https://github.com/woodruffw/kbs2/pull/304)) 105 | 106 | ## [0.5.0] - 2022-02-15 107 | 108 | ### Changed 109 | 110 | * Generators, Config: `kbs2` generators now support multiple input 111 | alphabets, making it easier to enforce character requirements 112 | ([#303](https://github.com/woodruffw/kbs2/pull/303)) 113 | 114 | * Meta: `kbs2` is now built with the 2021 edition of Rust 115 | ([#239](https://github.com/woodruffw/kbs2/pull/239)) 116 | 117 | * Config: `kbs2` now checks for a `config.toml` file for its configuration. 118 | The legacy behavior (`kbs2.conf`) is preserved for backwards compatibility, but 119 | will be removed in an upcoming stable release. 120 | ([#268](https://github.com/woodruffw/kbs2/pull/268)) 121 | 122 | ## [0.4.0] - 2021-10-20 123 | 124 | ### Added 125 | 126 | * CLI: `kbs2 dump` can now dump multiple records in one invocation 127 | ([#191](https://github.com/woodruffw/kbs2/pull/191)) 128 | * CLI: `kbs2 rm` can now remove multiple records in one invocation 129 | ([#195](https://github.com/woodruffw/kbs2/pull/195)) 130 | * CLI: `kbs2 agent query` enables users to query the agent for the status 131 | of a config's keypair ([#197](https://github.com/woodruffw/kbs2/pull/197)) 132 | * CLI: `kbs2 --completions` now supports more shells (`bash`, `elvish`, `fish`, 133 | `powershell`, and `zsh`) ([#235](https://github.com/woodruffw/kbs2/pull/235)) 134 | 135 | ### Changed 136 | 137 | * Agent: The agent's internal representation and protocol have been refactored. 138 | Releases earlier than this one use an incompatible protocol; users should 139 | run `kbs2 agent flush -q` after upgrading to kill their outdated agent 140 | ([#193](https://github.com/woodruffw/kbs2/pull/193)) 141 | * Deps: `kbs2` now uses `age` 0.7 ([#237](https://github.com/woodruffw/kbs2/pull/237)) 142 | 143 | ### Fixed 144 | 145 | * Contrib: `kbs2 choose-pass` no longer incorrectly nags the user when `choose` 146 | is canceled. 147 | 148 | ## [0.3.0] - 2021-05-02 149 | 150 | ### Added 151 | 152 | * CLI: `kbs2 rekey` enables users to rekey their entire secret store, re-encrypting 153 | all records with a new secret key. `kbs2 rekey` also handles the chore work of 154 | updating the user's config and related files for the new key. 155 | 156 | ### Changed 157 | 158 | * Contrib: The `kbs2-dmenu-pass` and `kbs2-choose-pass` commands now understand the 159 | `notify-username` (`bool`) setting, which allows them to send a desktop notification 160 | for the copied record's username. 161 | * Config, Contrib: External commands now use the `[commands.ext.]` namespace 162 | instead of `[commands.]`. 163 | 164 | ## [0.2.6] - 2021-02-20 165 | 166 | ### Added 167 | 168 | * Meta: The CHANGELOG and README are now semi-managed by `cargo release` 169 | * Contrib: Added `kbs2-ssh-add` 170 | * Control: Added `kbs2-gpg-add` 171 | * Contrib: `kbs2-snip` can now print instead of running snippet with `-p`, `--print` 172 | * CLI: Custom subcommands now receive `KBS2_MAJOR_VERSION`, `KBS2_MINOR_VERSION`, and 173 | `KBS2_PATCH_VERSION` in their environments 174 | * CLI: `kbs2 list` and `kbs2 dump` now use a more Unix-y format output 175 | 176 | ### Changed 177 | 178 | * Backend: The encryption backend now uses a default work factor of `22`, up from `18` 179 | 180 | ## [0.2.5] - 2020-12-12 181 | 182 | ### Fixed 183 | 184 | * Tests: Removed some overly conservative assertions with config directories 185 | 186 | ## [0.2.4] - 2020-12-10 187 | 188 | ### Fixed 189 | 190 | * CLI: Fixed the functionality of `kbs2 init --insecure-not-wrapped`, broken 191 | during an earlier refactor 192 | 193 | ## [0.2.3] - 2020-12-10 194 | 195 | ### Added 196 | 197 | * CLI: `kbs2 init` now supports `-s`/`--store-dir` for configuring the record store at 198 | config initialization time ([#123](https://github.com/woodruffw/kbs2/pull/118)) 199 | 200 | ## [0.2.2] - 2020-12-06 201 | 202 | ### Added 203 | 204 | * Config: `agent-autostart` now controls whether `kbs2 agent` is auto-spawned whenever a session is 205 | requested ([#118](https://github.com/woodruffw/kbs2/pull/118)) 206 | 207 | ### Changed 208 | 209 | * Agent: Users no longer have to manually run `kbs2 agent`; most commands will now auto-start the 210 | agent by default ([#118](https://github.com/woodruffw/kbs2/pull/118)) 211 | 212 | ### Fixed 213 | 214 | * Config: `wrapped` now always defaults to `true` ([#118](https://github.com/woodruffw/kbs2/pull/118)) 215 | 216 | ## [0.2.1] - 2020-12-05 217 | 218 | ### Added 219 | 220 | * Packaging: AUR is now supported. ([#89](https://github.com/woodruffw/kbs2/pull/89)) 221 | * CLI: `kbs2 agent` (and subcommands) now provide key persistence, replacing the original POSIX SHM 222 | implementation ([#103](https://github.com/woodruffw/kbs2/pull/103)) 223 | * CLI: `kbs2 rewrap` enables users to change the master password on their wrapped key(s) 224 | ([#107](https://github.com/woodruffw/kbs2/pull/107)) 225 | * Config: Users can now specify a custom Pinentry binary for prompts via the `pinentry` field 226 | ([#108](https://github.com/woodruffw/kbs2/pull/108)) 227 | * Config, Hooks: Support for an `error-hook` was added 228 | ([#117](https://github.com/woodruffw/kbs2/pull/117)) 229 | 230 | ### Changed 231 | 232 | * External commands: external commands run via `kbs2 {EXTERNAL}` that exit with an error now 233 | cause `kbs2` to exit with 1, instead of 2. 234 | 235 | ### Removed 236 | 237 | * CLI: `kbs2 lock` and `kbs2 unlock` were removed entirely as part of the `kbs2 agent` refactor. 238 | 239 | 240 | [Unreleased]: https://github.com/woodruffw/kbs2/compare/v0.7.2...HEAD 241 | [0.7.2]: https://github.com/woodruffw/kbs2/compare/v0.7.1...v0.7.2 242 | [0.7.1]: https://github.com/woodruffw/kbs2/compare/v0.7.0...v0.7.1 243 | [0.7.0]: https://github.com/woodruffw/kbs2/compare/v0.6.0...v0.7.0 244 | [0.6.0]: https://github.com/woodruffw/kbs2/compare/v0.5.1...v0.6.0 245 | [0.5.1]: https://github.com/woodruffw/kbs2/compare/v0.5.0...v0.5.1 246 | [0.5.0]: https://github.com/woodruffw/kbs2/compare/v0.4.0...v0.5.0 247 | [0.4.0]: https://github.com/woodruffw/kbs2/compare/v0.3.0...v0.4.0 248 | [0.3.0]: https://github.com/woodruffw/kbs2/compare/v0.2.6...v0.3.0 249 | [0.2.6]: https://github.com/woodruffw/kbs2/compare/v0.2.5...v0.2.6 250 | [0.2.5]: https://github.com/woodruffw/kbs2/releases/tag/v0.2.5 251 | [0.2.4]: https://github.com/woodruffw/kbs2/releases/tag/v0.2.4 252 | [0.2.3]: https://github.com/woodruffw/kbs2/releases/tag/v0.2.3 253 | [0.2.2]: https://github.com/woodruffw/kbs2/releases/tag/v0.2.2 254 | [0.2.1]: https://github.com/woodruffw/kbs2/releases/tag/v0.2.1 255 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kbs2" 3 | description = "A secret manager backed by age" 4 | license = "MIT" 5 | homepage = "https://github.com/woodruffw/kbs2" 6 | repository = "https://github.com/woodruffw/kbs2" 7 | version = "0.7.3-rc.1" 8 | authors = ["William Woodruff "] 9 | edition = "2021" 10 | readme = "README.md" 11 | keywords = ["cli", "password-manager", "crypto"] 12 | categories = ["command-line-utilities", "cryptography"] 13 | 14 | [package.metadata.release] 15 | publish = false # handled by GitHub Actions 16 | push = true 17 | 18 | [[package.metadata.release.pre-release-replacements]] 19 | file = "README.md" 20 | # TODO: https://github.com/sunng87/cargo-release/issues/241 21 | search = "\\d+\\.\\d+\\.\\d+" 22 | replace = "{{version}}" 23 | exactly = 3 24 | 25 | [[package.metadata.release.pre-release-replacements]] 26 | file = "CHANGELOG.md" 27 | search = "Unreleased" 28 | replace = "{{version}}" 29 | exactly = 2 30 | 31 | [[package.metadata.release.pre-release-replacements]] 32 | file = "CHANGELOG.md" 33 | search = "ReleaseDate" 34 | replace = "{{date}}" 35 | exactly = 1 36 | 37 | [[package.metadata.release.pre-release-replacements]] 38 | file = "CHANGELOG.md" 39 | search = "\\.\\.\\.HEAD" 40 | replace = "...{{tag_name}}" 41 | exactly = 1 42 | 43 | [[package.metadata.release.pre-release-replacements]] 44 | file = "CHANGELOG.md" 45 | search = "" 46 | replace = "\n\n## [Unreleased] - ReleaseDate" 47 | exactly = 1 48 | 49 | [[package.metadata.release.pre-release-replacements]] 50 | file = "CHANGELOG.md" 51 | search = "" 52 | replace = "\n[Unreleased]: https://github.com/woodruffw/kbs2/compare/{{tag_name}}...HEAD" 53 | exactly = 1 54 | 55 | [package.metadata.deb] 56 | depends = "$auto, libxcb-shape0-dev, libxcb-xfixes0-dev" 57 | 58 | [badges] 59 | maintenance = { status = "actively-developed" } 60 | 61 | [dependencies] 62 | age = { version = "0.10", features = ["armor"] } 63 | anyhow = "1.0" 64 | arboard = "3.4" 65 | clap = { version = "4.5", features = ["deprecated", "env"] } 66 | clap_complete = "4.5" 67 | daemonize = "0.5" 68 | env_logger = "0.10" 69 | home = "0.5" 70 | inquire = "0.6" 71 | lazy_static = "1.5" 72 | libc = "0.2" 73 | log = "0.4" 74 | nix = { version = "0.29", features = ["process", "user", "socket"] } 75 | pinentry = "0.5" 76 | rand = "0.8" 77 | rpassword = "7.3" 78 | secrecy = "0.8" 79 | serde = { version = "1.0", features = ["derive"] } 80 | serde_json = "1.0" 81 | shellexpand = "3.1" 82 | shell-words = "1.1" 83 | tempfile = "3" 84 | toml = "0.8" 85 | whoami = "1.5" 86 | xdg = "2.5" 87 | 88 | [dev-dependencies] 89 | assert_cmd = "2" 90 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 William Woodruff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::process::Command; 3 | 4 | fn main() { 5 | let mut version = String::from(env!("CARGO_PKG_VERSION")); 6 | if let Some(commit_hash) = commit_hash() { 7 | version = format!("{version} ({commit_hash})"); 8 | } 9 | println!("cargo:rustc-env=KBS2_BUILD_VERSION={version}"); 10 | } 11 | 12 | // Cribbed from Alacritty: 13 | // https://github.com/alacritty/alacritty/blob/8ea6c3b/alacritty/build.rs 14 | fn commit_hash() -> Option { 15 | Command::new("git") 16 | .args(["rev-parse", "--short", "HEAD"]) 17 | .output() 18 | .ok() 19 | .filter(|output| output.status.success()) 20 | .and_then(|output| String::from_utf8(output.stdout).ok()) 21 | .map(|hash| hash.trim().into()) 22 | } 23 | -------------------------------------------------------------------------------- /contrib/README.md: -------------------------------------------------------------------------------- 1 | Contributed resources 2 | ===================== 3 | 4 | This directory is a central resource for contributions to the `kbs2` ecosystem. 5 | 6 | In particular: 7 | 8 | * [ext-cmds](ext-cmds/) contains external `kbs2` commands 9 | * [hooks](hooks/) contains useful hooks 10 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-audit-pass/README.md: -------------------------------------------------------------------------------- 1 | `kbs2-audit-pass` 2 | ================= 3 | 4 | `kbs2-audit-pass` is an external `kbs2` command that uses the 5 | Have I Been Pwned? ["Pwned Passwords"](https://haveibeenpwned.com/API/v3#PwnedPasswords) 6 | service to check whether a user's passwords are included in a breach. 7 | 8 | ## Setup 9 | 10 | `curl` and `shasum` are required. 11 | 12 | ## Usage 13 | 14 | Auditing every login in the store: 15 | 16 | ```bash 17 | kbs2 audit-pass --all 18 | ``` 19 | 20 | Auditing just the listed logins: 21 | 22 | ```bash 23 | kbs2 audit-pass email facebook amazon 24 | ``` 25 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-audit-pass/kbs2-audit-pass: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # kbs2-audit-pass: Test the given (or all) login records against Have I Been Pwned? 4 | 5 | set -eo pipefail 6 | 7 | [[ -n "${KBS2_SUBCOMMAND}" ]] \ 8 | || { >&2 echo "Fatal: Not being run as a subcommand?"; exit 1; } 9 | 10 | HELP="\ 11 | kbs2 audit-pass 12 | Check whether a password has been exposed in a data break 13 | 14 | USAGE: 15 | kbs2 audit-pass [FLAGS] [LABEL ...] 16 | 17 | ARGS: 18 | [LABEL ...] The labels of the login records to audit 19 | 20 | FLAGS: 21 | -h, --help Print this help message 22 | -a, --all Audit all login records, not just the specified ones 23 | " 24 | 25 | if [[ "${1}" == "-h" || "${1}" == "--help" ]]; then 26 | echo "${HELP}" 27 | exit 28 | elif [[ "${1}" == "-a" || "${1}" == "--all" ]]; then 29 | readarray -t labels < <(kbs2 list -k login) 30 | else 31 | labels=("${@}") 32 | fi 33 | 34 | if [[ "${#labels[@]}" -eq 0 ]]; then 35 | >&2 echo "Fatal: No labels specified or no login records in store" 36 | exit 1 37 | fi 38 | 39 | for label in "${labels[@]}"; do 40 | >&2 echo "[+] Auditing ${label}..." 41 | digest=$(shasum -a 1 < <(kbs2 pass "${label}" | tr -d '\n') | awk '{ print $1 }') 42 | digest="${digest^^}" 43 | prefix="${digest:0:5}" 44 | 45 | readarray -t matches < \ 46 | <( \ 47 | curl \ 48 | -s \ 49 | -A "kbs2-audit-pass (https://github.com/woodruffw/kbs2)" \ 50 | -H "Add-Padding: true" \ 51 | "https://api.pwnedpasswords.com/range/${prefix}" \ 52 | | tr -d '\r' # CRLF was a mistake 53 | ) 54 | 55 | for match in "${matches[@]}"; do 56 | suffix=$(awk -F: '{ print $1 }' <<< "${match}") 57 | count=$(awk -F: '{ print $2 }' <<< "${match}") 58 | 59 | if [[ "${prefix}${suffix}" == "${digest}" ]]; then 60 | >&2 echo "[!] ${label} has ${count} hits on HIBP!" 61 | fi 62 | done 63 | 64 | sleep 2 65 | done 66 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-choose-pass/README.md: -------------------------------------------------------------------------------- 1 | `kbs2-choose-pass` 2 | ================= 3 | 4 | `kbs2-choose-pass` is an external `kbs2` command that uses `choose` to 5 | select a login and `kbs2 pass` to copy the selected login's password 6 | to the clipboard. 7 | 8 | `kbs2-choose-pass` only works on macOS. 9 | 10 | ## Setup 11 | 12 | `kbs2-choose-pass` requires [`choose`](https://github.com/chipsenkbeil/choose), which is available 13 | via Homebrew: 14 | 15 | ```bash 16 | $ brew install choose-gui 17 | ``` 18 | 19 | [`toml2json`](https://github.com/woodruffw/toml2json) and `jq` are optional 20 | dependencies. See the configuration section for details. 21 | 22 | ## Configuration 23 | 24 | `kbs2 choose-pass` reads the `commands.ext.choose-pass.notify-username` setting. If `true`, 25 | a desktop notification is emitted containing the username of the record that 26 | the user has selected (and is currently in the clipboard). 27 | 28 | To read the configuration, `kbs2 choose-pass` requires both `toml2json` and `jq`. 29 | If either is missing, the configuration will be silently ignored. 30 | 31 | ## Usage 32 | 33 | From the command line: 34 | 35 | ```bash 36 | kbs2 choose-pass 37 | ``` 38 | 39 | ### "Quick Action" (Touch Bar) 40 | 41 | If you have a Touch Bar, you can use `kbs2-choose-pass` as a "Quick Action". 42 | 43 | An installable workflow is provided in this directory. 44 | 45 | ### Karabiner-Elements 46 | 47 | Alternatively, if you use [Karabiner-Elements](https://github.com/pqrs-org/Karabiner-Elements), 48 | you can use the binding provided [here](./kbs2-choose-pass.karabiner.json). By default, F12 49 | (Function-12) is bound to `kbs2 choose-pass`. 50 | 51 | ```bash 52 | cp kbs2-choose-pass.karabiner.json ~/.config/karabiner/assets/complex_modifications/kbs2.json 53 | ``` 54 | 55 | ...and add "`kbs2-choose-pass`" in the "complex modifications" pane. 56 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-choose-pass/kbs2-choose-pass: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # kbs2-choose-pass: List all kbs2 logins in choose, feed the selected one into the clipboard. 4 | 5 | set -eo pipefail 6 | 7 | [[ -n "${KBS2_SUBCOMMAND}" ]] \ 8 | || { >&2 echo "Fatal: Not being run as a subcommand?"; exit 1; } 9 | 10 | function installed() { 11 | cmd=$(command -v "${1}") 12 | 13 | [[ -n "${cmd}" ]] && [[ -f "${cmd}" ]] 14 | return ${?} 15 | } 16 | 17 | function maybe_notify_username() { 18 | label="${1}" 19 | 20 | installed jq || return 21 | 22 | setting=$( 23 | kbs2 config dump \ 24 | | jq --raw-output '.commands.ext."choose-pass"."notify-username" or false' 25 | ) 26 | 27 | if [[ "${setting}" == "true" ]]; then 28 | username=$(kbs2 dump -j "${label}" | jq -r '.body.fields.username') 29 | osascript -e "display notification \"${label}: copied password for ${username}\" with title \"kbs2 choose-pass\"" 30 | fi 31 | } 32 | 33 | labels=$(kbs2 list -k login) 34 | label=$(choose <<< "${labels}") || exit 0 35 | 36 | # NOTE(ww): Exit with a success if the user canceled, to avoid nagging 37 | # them with an error-hook. 38 | [[ -z "${label}" ]] && exit 0 39 | 40 | maybe_notify_username "${label}" 41 | 42 | kbs2 pass -c "${label}" 43 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-choose-pass/kbs2-choose-pass.karabiner.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": [ 3 | { 4 | "manipulators": [ 5 | { 6 | "description": "choose-pass", 7 | "type": "basic", 8 | "to": [ 9 | { 10 | "shell_command": "/bin/bash -lc 'kbs2 choose-pass'" 11 | } 12 | ], 13 | "from": { 14 | "key_code": "f12" 15 | } 16 | } 17 | ] 18 | } 19 | ], 20 | "title": "kbs2 choose-pass" 21 | } 22 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-choose-pass/kbs2-choose-pass.workflow/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSServices 6 | 7 | 8 | NSBackgroundColorName 9 | background 10 | NSBackgroundSystemColorName 11 | blackColor 12 | NSIconName 13 | NSTouchBarDocuments 14 | NSMenuItem 15 | 16 | default 17 | kbs2 18 | 19 | NSMessage 20 | runWorkflowAsService 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-choose-pass/kbs2-choose-pass.workflow/Contents/document.wflow: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AMApplicationBuild 6 | 492 7 | AMApplicationVersion 8 | 2.10 9 | AMDocumentVersion 10 | 2 11 | actions 12 | 13 | 14 | action 15 | 16 | AMAccepts 17 | 18 | Container 19 | List 20 | Optional 21 | 22 | Types 23 | 24 | com.apple.applescript.object 25 | 26 | 27 | AMActionVersion 28 | 1.0.2 29 | AMApplication 30 | 31 | Automator 32 | 33 | AMParameterProperties 34 | 35 | source 36 | 37 | 38 | AMProvides 39 | 40 | Container 41 | List 42 | Types 43 | 44 | com.apple.applescript.object 45 | 46 | 47 | ActionBundlePath 48 | /System/Library/Automator/Run AppleScript.action 49 | ActionName 50 | Run AppleScript 51 | ActionNameComment 52 | kbs2 53 | ActionParameters 54 | 55 | source 56 | -- NOTE: We grab the user's configured shell and run it in "login" mode -- below to ensure that $PATH has kbs2 + kbs2-choose-pass in it. set userShell to do shell script "perl -e " & quoted form of "@x=getpwuid($<); print $x[8]" -- NOTE: We do `|| true` here to stop macOS from complaining when -- kbs2 exits with a non-zero code (e.g., due to canceled input) do shell script userShell & " -lc " & quoted form of "kbs2 choose-pass" & " || true" 57 | 58 | BundleIdentifier 59 | com.apple.Automator.RunScript 60 | CFBundleVersion 61 | 1.0.2 62 | CanShowSelectedItemsWhenRun 63 | 64 | CanShowWhenRun 65 | 66 | Category 67 | 68 | AMCategoryUtilities 69 | 70 | Class Name 71 | RunScriptAction 72 | InputUUID 73 | 582750B1-19DD-4083-AC9D-A75DED166022 74 | Keywords 75 | 76 | Run 77 | 78 | OutputUUID 79 | 856300A4-7F57-43F0-B376-AD0B7E811752 80 | UUID 81 | 169B0351-5D93-48A1-96DF-935668564E5B 82 | UnlocalizedApplications 83 | 84 | Automator 85 | 86 | arguments 87 | 88 | 0 89 | 90 | default value 91 | on run {input, parameters} 92 | 93 | (* Your script goes here *) 94 | 95 | return input 96 | end run 97 | name 98 | source 99 | required 100 | 0 101 | type 102 | 0 103 | uuid 104 | 0 105 | 106 | 107 | isViewVisible 108 | 109 | location 110 | 309.000000:368.000000 111 | nibPath 112 | /System/Library/Automator/Run AppleScript.action/Contents/Resources/Base.lproj/main.nib 113 | 114 | isViewVisible 115 | 116 | 117 | 118 | connectors 119 | 120 | workflowMetaData 121 | 122 | applicationBundleIDsByPath 123 | 124 | applicationPaths 125 | 126 | backgroundColorName 127 | blackColor 128 | inputTypeIdentifier 129 | com.apple.Automator.nothing 130 | outputTypeIdentifier 131 | com.apple.Automator.nothing 132 | presentationMode 133 | 11 134 | processesInput 135 | 0 136 | serviceInputTypeIdentifier 137 | com.apple.Automator.nothing 138 | serviceOutputTypeIdentifier 139 | com.apple.Automator.nothing 140 | serviceProcessesInput 141 | 0 142 | systemImageName 143 | NSTouchBarDocuments 144 | useAutomaticInputType 145 | 0 146 | workflowTypeIdentifier 147 | com.apple.Automator.servicesMenu 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-dmenu-pass/README.md: -------------------------------------------------------------------------------- 1 | `kbs2-dmenu-pass` 2 | ================= 3 | 4 | `kbs2-dmenu-pass` is an external `kbs2` command that uses `dmenu` to 5 | select a login and `kbs2 pass` to copy the selected login's password 6 | to the clipboard. 7 | 8 | `kbs2-dmenu-pass` only works on systems running X11. 9 | 10 | ## Setup 11 | 12 | `kbs2-dmenu-pass` requires [`dmenu`](https://tools.suckless.org/dmenu/) by 13 | default. Your package manager should supply it. 14 | 15 | [`jq`](https://stedolan.github.io/jq/) is required for config handling. 16 | Your package manager should supply it. 17 | 18 | ## Configuration 19 | 20 | ### `commands.ext.dmenu-pass.notify-username` (boolean) 21 | 22 | If `true`, a desktop notification is emitted containing the username of the 23 | record that the user has selected (and is currently in the clipboard). 24 | 25 | ### `commands.ext.dmenu-pass.chooser` (string) 26 | 27 | If set, `kbs2-dmenu-pass` will execute this string as a `dmenu`-compatible chooser. 28 | 29 | For example, to use [`rofi`](https://github.com/davatorium/rofi): 30 | 31 | ```toml 32 | [commands.ext.dmenu-pass] 33 | chooser = "rofi -dmenu -p kbs2" 34 | ``` 35 | 36 | ## Usage 37 | 38 | ```bash 39 | kbs2 dmenu-pass 40 | ``` 41 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-dmenu-pass/kbs2-dmenu-pass: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # kbs2-dmenu-pass: List all kbs2 logins in dmenu, feed the selected one into the clipboard. 4 | 5 | set -eo pipefail 6 | 7 | [[ -n "${KBS2_SUBCOMMAND}" ]] \ 8 | || { >&2 echo "Fatal: Not being run as a subcommand?"; exit 1; } 9 | 10 | config="$(kbs2 config dump)" 11 | 12 | function installed() { 13 | cmd=$(command -v "${1}") 14 | 15 | [[ -n "${cmd}" ]] && [[ -f "${cmd}" ]] 16 | return ${?} 17 | } 18 | 19 | function maybe_notify_username() { 20 | label="${1}" 21 | 22 | installed jq || return 23 | 24 | setting=$(jq --raw-output '.commands.ext."dmenu-pass"."notify-username" or false' <<< "${config}") 25 | 26 | if [[ "${setting}" == "true" ]]; then 27 | username=$(kbs2 dump -j "${label}" | jq -r '.body.fields.username') 28 | notify-send "kbs2 dmenu-pass" "${label}: copied password for ${username}" 29 | fi 30 | } 31 | 32 | chooser=$(jq --raw-output '.commands.ext."dmenu-pass".chooser // "dmenu -p kbs2"' <<< "${config}") 33 | 34 | labels=$(kbs2 list -k login) 35 | 36 | # NOTE(ww): dmenu and similar tools exit with 1 when canceled; use `|| exit 0` here to ignore 37 | # `set -e` so that we can check whether label is empty immediately below. 38 | label=$(${chooser} <<< "${labels}" || exit 0) 39 | 40 | # NOTE(ww): Exit with a success if the user canceled, to avoid nagging 41 | # them with an error-hook. 42 | [[ -z "${label}" ]] && exit 0 43 | 44 | maybe_notify_username "${label}" 45 | 46 | kbs2 pass -c "${label}" 47 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-git-ssh-signing/README.md: -------------------------------------------------------------------------------- 1 | `kbs2-git-ssh-signing` 2 | ====================== 3 | 4 | `kbs2-git-ssh-signing` is an external `kbs2` command that: 5 | 6 | 1. Loads an SSH key stored in `kbs2` into your SSH agent via `ssh-add`. 7 | 2. Emits the public component of the private key in a format 8 | that `git` understands, so that the output of this command 9 | can be used by `git`'s SSH signing support. 10 | 11 | ## Setup 12 | 13 | `kbs2-git-ssh-signing` requires `ssh-add` (obviously) and `jq`. 14 | 15 | To use it, load your SSH key of choice into `kbs2`: 16 | 17 | ```bash 18 | # replace id_ed25519 with id_rsa or whatever your actual key is 19 | kbs2-new -k unstructured your-ssh-key --terse < ~/.ssh/id_ed25519 20 | ``` 21 | 22 | ## Usage 23 | 24 | `kbs2-git-ssh-signing` loads the given record into your SSH agent, and 25 | prints it in the `key::` format that `git` expects for SSH signing keys: 26 | 27 | ```bash 28 | $ kbs2 git-ssh-signing your-ssh-signing-key 29 | key::ssh-ed25519 YOUR-PUBKEY-HERE YOUR@IDENTITY.HERE 30 | ``` 31 | 32 | This can done on-demand via the following `~/.gitconfig` settings: 33 | 34 | ```ini 35 | [gpg "ssh"] 36 | defaultKeyCommand = kbs2 git-ssh-signing your-ssh-signing-key 37 | ``` 38 | 39 | Note that you'll still be prompted for your SSH key's password if you choose to additionally 40 | password-protect it. 41 | 42 | ## Resources 43 | 44 | * [`git`'s documentation for `gpg.ssh.defaultKeyCommand`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgsshdefaultKeyCommand) 45 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-git-ssh-signing/kbs2-git-ssh-signing: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # kbs2-git-ssh-signing: Add a kbs2-stored key to the ssh-agent, 4 | # and emit its public half in git's expected SSH signing key format. 5 | 6 | set -eo pipefail 7 | 8 | [[ -n "${KBS2_SUBCOMMAND}" ]] \ 9 | || { >&2 echo "Fatal: Not being run as a subcommand?"; exit 1; } 10 | 11 | record="${1}" 12 | 13 | [[ -n "${1}" ]] || { >&2 echo "Usage: kbs2 git-ssh-signing "; exit; } 14 | 15 | privkey="$(kbs2 dump --json "${record}" | jq --raw-output ".body.fields.contents")" 16 | 17 | # Add the key to the ssh-agent, in case it isn't already present. 18 | ssh-add -q - <<< "${privkey}" 19 | 20 | # Re-derive the public key from the private key, so we can emit 21 | # it in `key::` format for git. 22 | # We have to do through this through a named pipe instead of 23 | # a normal pipeline because `ssh-keygen -y` doesn't understand 24 | # `-f -` for stdin, and `/dev/stdin` isn't guaranteed to have the 25 | # right permission bits (e.g. on macOS, where it has 0660 instead 26 | # of SSH's expected 0600). 27 | fifo=$(mktemp -u) 28 | mkfifo -m 600 "${fifo}" 29 | 30 | # NOTE: intentional pre-expansion here. 31 | # shellcheck disable=SC2064 32 | trap "rm ${fifo}" EXIT 33 | 34 | # NOTE: pipeline to ssh-keygen here only to resolve 35 | # the deadlock; the private key material goes through the FIFO. 36 | # SC doesn't like this because it can't see that the path 37 | # on both ends is a FIFO; if it wasn't, this would be a truncation bug. 38 | # shellcheck disable=SC2094,2260 39 | pubkey=$(cat <<< "${privkey}" > "${fifo}" | ssh-keygen -y -f "${fifo}") 40 | 41 | # NOTE: experimentally, the `key::` prefix is not required (since 42 | # the key here is already in `ssh-fmt ...` form). Including it causes 43 | # errors on some hosts, particularly Ubuntu 22.04 with git 2.34.1. 44 | echo "${pubkey}" 45 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-gpg-add/README.md: -------------------------------------------------------------------------------- 1 | `kbs2-gpg-add` 2 | =========== 3 | 4 | `kbs2-gpg-add` is an external `kbs2` command that loads an GPG passphrase stored in `kbs2` 5 | into your GPG agent via `gpg-preset-passphrase`. 6 | 7 | ## Setup 8 | 9 | `kbs2-gpg-add` requires GnuPG2 and `jq`. 10 | 11 | To use it, you'll need to configure your GPG agent to allow preset passphrases: 12 | 13 | ``` 14 | # ~/.gnupg/gpg-agent.conf or wherever 15 | allow-preset-passphrase 16 | ``` 17 | 18 | Then, restart your GPG agent. Sending it `SIGHUP` won't work for this setting. 19 | 20 | You'll also need to get your GPG keygrip: 21 | 22 | ```bash 23 | gpg --list-keys --with-keygrip 24 | ``` 25 | 26 | Finally, load your GPG keygrip and passphrase into `kbs2`: 27 | 28 | ```bash 29 | # set your username to your keygrip, and password to your passphrase. 30 | kbs2 new gpg-passphrase-record 31 | ``` 32 | 33 | ## Usage 34 | 35 | `kbs2-gpg-add` loads the given record into your GPG agent, associating 36 | the passphrase with the keygrip: 37 | 38 | ```bash 39 | $ kbs2 gpg-add gpg-passphrase-record 40 | ``` 41 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-gpg-add/kbs2-gpg-add: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # kbs2-gpg-add: Add a kbs2-stored GPG passphrase to the gpg-agent. 4 | 5 | set -eo pipefail 6 | 7 | [[ -n "${KBS2_SUBCOMMAND}" ]] \ 8 | || { >&2 echo "Fatal: Not being run as a subcommand?"; exit 1; } 9 | 10 | record="${1}" 11 | gpg_preset_passphrase=/usr/lib/gnupg2/gpg-preset-passphrase 12 | 13 | [[ -f "${gpg_preset_passphrase}" ]] \ 14 | || { >&2 echo "Fatal: couldn't find gpg-preset-passphrase"; exit 1; } 15 | 16 | [[ -n "${record}" ]] \ 17 | || { >&2 echo "Usage: kbs2 gpg-add "; exit; } 18 | 19 | contents=$(kbs2 dump --json "${record}") 20 | keygrip=$(jq --raw-output ".body.fields.username" <<< "${contents}") 21 | passphrase=$(jq --raw-output ".body.fields.password" <<< "${contents}") 22 | 23 | "${gpg_preset_passphrase}" --preset "${keygrip}" <<< "${passphrase}" 24 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-kbsecret-env-import/README.md: -------------------------------------------------------------------------------- 1 | `kbs2-kbsecret-env-import` 2 | ========================== 3 | 4 | `kbs2-kbsecret-env-import` is an external `kbs2` command that imports all environment records 5 | from a KBSecret session into the `kbs2` store. 6 | 7 | ## Setup 8 | 9 | `kbsecret` is required. 10 | 11 | ## Usage 12 | 13 | Import environment records from the default session: 14 | 15 | ```bash 16 | $ kbs2 kbsecret-env-import 17 | ``` 18 | 19 | Import environment records from the "api-keys" session: 20 | 21 | ```bash 22 | $ kbs2 kbsecret-env-import api-keys 23 | ``` 24 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-kbsecret-env-import/kbs2-kbsecret-env-import: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # frozen_string_literal: true 3 | 4 | # kbs2-kbsecret-env-import: Import environment records from a KBSecret session into kbs2 5 | 6 | set -eo pipefail 7 | 8 | [[ -n "${KBS2_SUBCOMMAND}" ]] \ 9 | || { >&2 echo "Fatal: Not being run as a subcommand?"; exit 1; } 10 | 11 | session="${1:-default}" 12 | kind="environment" 13 | 14 | for label in $(kbsecret list -s "${session}" -t "${kind}"); do 15 | record=$(kbsecret env -s "${session}" -n "${label}") 16 | 17 | variable=$(awk -F = '{ print $1 }' <<< "${record}") 18 | value=$(awk -F = '{ print $2 }' <<< "${record}") 19 | 20 | kbs2 new -k environment "${label}" <<< "${variable}"$'\x01'"${value}" 21 | echo "[+] Imported ${label}" 22 | done 23 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-kbsecret-login-import/README.md: -------------------------------------------------------------------------------- 1 | `kbs2-kbsecret-login-import` 2 | ========================== 3 | 4 | `kbs2-kbsecret-login-import` is an external `kbs2` command that imports all login records 5 | from a KBSecret session into the `kbs2` store. 6 | 7 | ## Setup 8 | 9 | `kbsecret` is required. 10 | 11 | ## Usage 12 | 13 | Import login records from the default session: 14 | 15 | ```bash 16 | $ kbs2 kbsecret-login-import 17 | ``` 18 | 19 | Import login records from the "extra-logins" session: 20 | 21 | ```bash 22 | $ kbs2 kbsecret-login-import extra-logins 23 | ``` 24 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-kbsecret-login-import/kbs2-kbsecret-login-import: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # frozen_string_literal: true 3 | 4 | # kbs2-kbsecret-login-import: Import login records from a KBSecret session into kbs2 5 | 6 | set -eo pipefail 7 | 8 | [[ -n "${KBS2_SUBCOMMAND}" ]] \ 9 | || { >&2 echo "Fatal: Not being run as a subcommand?"; exit 1; } 10 | 11 | session="${1:-default}" 12 | kind="login" 13 | 14 | for label in $(kbsecret list -s "${session}" -t "${kind}"); do 15 | record=$(kbsecret login -s "${session}" -i $'\x01' -x "${label}") 16 | 17 | username=$(awk -F $'\x01' '{ print $2 }' <<< "${record}") 18 | password=$(awk -F $'\x01' '{ print $3 }' <<< "${record}") 19 | 20 | kbs2 new "${label}" <<< "${username}"$'\x01'"${password}" 21 | echo "[+] Imported ${label}" 22 | done 23 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-qr/README.md: -------------------------------------------------------------------------------- 1 | `kbs2-qr` 2 | ========= 3 | 4 | `kbs2-qr` is an external `kbs2` command that displays the password of the requested login 5 | record as a scannable QR code. 6 | 7 | ## Setup 8 | 9 | `qrencode` is required on all systems. 10 | 11 | On non-macOS systems, `feh` is additionally required. 12 | 13 | ## Usage 14 | 15 | ```bash 16 | kbs2 qr email 17 | ``` 18 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-qr/kbs2-qr: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # kbs2-qr: Generate and display a QR code containing the password for a login record 4 | 5 | set -eo pipefail 6 | 7 | [[ -n "${KBS2_SUBCOMMAND}" ]] \ 8 | || { >&2 echo "Fatal: Not being run as a subcommand?"; exit 1; } 9 | 10 | function installed() { 11 | cmd=$(command -v "${1}") 12 | 13 | [[ -n "${cmd}" ]] && [[ -f "${cmd}" ]] 14 | return ${?} 15 | } 16 | 17 | installed qrencode || { >&2 echo "Missing dependency: qrencode."; exit 1; } 18 | 19 | if [[ $(uname -s) == "Darwin" ]]; then 20 | display="open -a Preview.app -f" 21 | else 22 | installed feh || { >&2 echo "Missing dependency: feh."; exit 1; } 23 | display="feh -" 24 | fi 25 | 26 | [[ -n "${1}" ]] || { echo "Usage: kbs2 qr "; exit; } 27 | 28 | password=$(kbs2 pass "${1}") 29 | [[ -z "${password}" ]] && { echo "No such login: ${1}"; exit 1; } 30 | 31 | qrencode -s 12 -o - <<< "${password}" | ${display} 32 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-snip/README.md: -------------------------------------------------------------------------------- 1 | `kbs2-snip` 2 | =========== 3 | 4 | `kbs2-snip` is an external `kbs2 command` that uses 5 | [`selecta`](https://github.com/garybernhardt/selecta) (or another fuzzy finder) 6 | to find and execute a snippet of code stored as an unstructured record. 7 | 8 | ## Setup 9 | 10 | `kbs2-snip` requires Ruby and the [tomlrb](https://github.com/fbernier/tomlrb) gem. 11 | 12 | By default, `kbs2-snip` requires `selecta`. 13 | 14 | See the configuration options below for alternatives. 15 | 16 | ## Configuration 17 | 18 | `kbs2-snip` reads the `commands.ext.snip.chooser` setting in the configuration 19 | file to determine which fuzzy finder to use. 20 | 21 | For example: 22 | 23 | ```toml 24 | [commands.snip] 25 | chooser = "fzf" 26 | ``` 27 | 28 | ...will cause `kbs2-snip` to use [`fzf`](https://github.com/junegunn/fzf). 29 | 30 | ## Usage 31 | 32 | `kbs2-snip` searches for unstructured records whose contents begin with `snippet:`. 33 | 34 | ```bash 35 | $ kbs2 new -k unstructured ls-tmp <<< "snippet:ls /tmp" 36 | $ kbs2 snip 37 | $ kbs2 snip -p # print instead of running 38 | ``` 39 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-snip/kbs2-snip: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # kbs2-snip: quickly select and run a code snippet stored in kbs2 using a fuzzy selector 5 | # By default, selecta is used as the fuzzy selector. 6 | 7 | require "open3" 8 | require "optparse" 9 | require "json" 10 | 11 | abort "Fatal: Not being run as a subcommand?" unless ENV.key? "KBS2_SUBCOMMAND" 12 | 13 | trap(:INT) { exit! } 14 | 15 | options = { print: false } 16 | OptionParser.new do |opts| 17 | opts.banner = "Usage: kbs2 snip [-p] record" 18 | 19 | opts.on "-p", "--[no-]print", "print the snippet instead of running it" do |o| 20 | options[:print] = o 21 | end 22 | end.parse! 23 | 24 | config = JSON.parse `kbs2 config dump` 25 | 26 | chooser = config.dig("commands", "ext", "snip", "chooser") || "selecta" 27 | 28 | unstructureds = `kbs2 list -k unstructured`.split 29 | snippets = unstructureds.filter_map do |label| 30 | record = JSON.parse `kbs2 dump -j #{label}` 31 | contents = record["body"]["fields"]["contents"] 32 | 33 | ["#{label} - #{contents[8..]}", contents[8..]] if contents.start_with? "snippet:" 34 | end.to_h 35 | 36 | output, = Open3.capture2 chooser, stdin_data: snippets.keys.join("\n") 37 | selection = output.chomp 38 | code = snippets[selection] 39 | 40 | return unless code 41 | 42 | if options[:print] 43 | puts code 44 | else 45 | exec code 46 | end 47 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-ssh-add/README.md: -------------------------------------------------------------------------------- 1 | `kbs2-ssh-add` 2 | =========== 3 | 4 | `kbs2-ssh-add` is an external `kbs2` command that loads an SSH key stored in `kbs2` 5 | into your SSH agent via `ssh-add`. 6 | 7 | ## Setup 8 | 9 | `kbs2-ssh-add` requires `ssh-add` (obviously) and `jq`. 10 | 11 | To use it, load your SSH key of choice into `kbs2`: 12 | 13 | ```bash 14 | # replace id_ed25519 with id_rsa or whatever your actual key is 15 | kbs2-new -k unstructured your-ssh-key --terse < ~/.ssh/id_ed25519 16 | ``` 17 | 18 | ## Usage 19 | 20 | `kbs2-ssh-add` loads the given record into your SSH agent: 21 | 22 | ```bash 23 | $ kbs2 ssh-add your-ssh-key 24 | ``` 25 | 26 | Note that you'll still be prompted for your SSH key's password if you choose to additionally 27 | password-protect it. 28 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-ssh-add/kbs2-ssh-add: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # kbs2-ssh-add: Add a kbs2-stored key to the ssh-agent. 4 | 5 | set -eo pipefail 6 | 7 | [[ -n "${KBS2_SUBCOMMAND}" ]] \ 8 | || { >&2 echo "Fatal: Not being run as a subcommand?"; exit 1; } 9 | 10 | record="${1}" 11 | 12 | [[ -n "${record}" ]] || { >&2 echo "Usage: kbs2 ssh-add "; exit; } 13 | 14 | kbs2 dump --json "${record}" \ 15 | | jq --raw-output ".body.fields.contents" \ 16 | | ssh-add - 17 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-yad-login/README.md: -------------------------------------------------------------------------------- 1 | `kbs2-yad-login` 2 | ================ 3 | 4 | `kbs2-yad-login` is an external `kbs2` command that creates a new login record 5 | using a [YAD](https://github.com/v1cont/yad)-generated form GUI. 6 | 7 | ## Setup 8 | 9 | `yad` is required. 10 | 11 | ## Usage 12 | 13 | ```bash 14 | $ kbs2 yad-login 15 | ``` 16 | -------------------------------------------------------------------------------- /contrib/ext-cmds/kbs2-yad-login/kbs2-yad-login: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # kbs2-yad-login: Add a login record to kbs2 via a YAD form. 4 | 5 | set -eo pipefail 6 | 7 | [[ -n "${KBS2_SUBCOMMAND}" ]] \ 8 | || { >&2 echo "Fatal: Not being run as a subcommand?"; exit 1; } 9 | 10 | default_username=$(kbs2 config dump | jq --raw-output '.commands.new."default-username"') 11 | 12 | record=$( 13 | yad \ 14 | --form \ 15 | --separator=$'\x01' \ 16 | --item-separator=$'\x01' \ 17 | --text-align=left \ 18 | --text="New Login" \ 19 | --title="kbs2" \ 20 | --field="Label" \ 21 | --field="Username" \ 22 | --field="Password:H" \ 23 | --field="Overwrite?:CHK" \ 24 | "" \ 25 | "${default_username}" \ 26 | "$(kbs2 generate)" \ 27 | "FALSE" 28 | ) 29 | 30 | label=$(awk -F $'\x01' '{ print $1 }' <<< "${record}") 31 | username=$(awk -F $'\x01' '{ print $2 }' <<< "${record}") 32 | password=$(awk -F $'\x01' '{ print $3 }' <<< "${record}") 33 | force=$(awk -F $'\x01' '{ print $4 }' <<< "${record}") 34 | 35 | if [[ "${force}" == "TRUE" ]]; then 36 | force_arg="--force" 37 | fi 38 | 39 | # NOTE: Intentional lack of quoting around `force_arg`, to allow empty expansion. 40 | kbs2 new ${force_arg} "${label}" <<< "${username}"$'\x01'"${password}" 41 | -------------------------------------------------------------------------------- /contrib/hooks/error-hook-notify/README.md: -------------------------------------------------------------------------------- 1 | `error-hook-notify` 2 | ================= 3 | 4 | `error-hook-notify` is a `kbs2` hook that displays a desktop notification 5 | when a `kbs2` subcommand fail. Failures in external subcommands are also 6 | reported. 7 | 8 | `error-hook-notify` supports Linux and macOS. 9 | 10 | ## Setup 11 | 12 | `error-hook-notify` requires `notify-send` on Linux. Most desktop 13 | distributions should include it. 14 | 15 | No special setup is required on macOS. 16 | 17 | ## Use 18 | 19 | Configure `error-hook-notify` as the `error-hook` for `kbs2`: 20 | 21 | ```toml 22 | error-hook = "~/.config/kbs2/hooks/error-hook-notify" 23 | ``` 24 | -------------------------------------------------------------------------------- /contrib/hooks/error-hook-notify/error-hook-notify: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | [[ -n "${KBS2_HOOK}" ]] \ 4 | || { >&2 echo "Fatal: Not being run as a hook?"; exit 1; } 5 | 6 | system=$(uname -s) 7 | 8 | if [[ "${system}" == Linux ]]; then 9 | notify-send --urgency=critical "kbs2" "Error: ${1}" 10 | elif [[ "${system}" == Darwin ]]; then 11 | # https://stackoverflow.com/a/23923108 12 | osascript \ 13 | -e 'on run(argv)' \ 14 | -e 'display notification ("Error: " & item 1 of argv) with title "kbs2"' \ 15 | -e 'end' \ 16 | -- "${1}" 17 | else 18 | >&2 echo "[+] Unsupported system: ${system}" 19 | fi 20 | -------------------------------------------------------------------------------- /contrib/hooks/pass-clear-notify/README.md: -------------------------------------------------------------------------------- 1 | `pass-clear-notify` 2 | ================= 3 | 4 | `pass-clear-notify` is a `kbs2` hook that displays a desktop notification 5 | after the clipboard has been cleared by `kbs2 pass`. 6 | 7 | `pass-clear-notify` supports Linux and macOS. 8 | 9 | ## Setup 10 | 11 | `pass-clear-notify` requires `notify-send` on Linux. Most desktop 12 | distributions should include it. 13 | 14 | No special setup is required on macOS. 15 | 16 | ## Use 17 | 18 | Configure `pass-clear-notify` as the `clear-hook` for `kbs2 pass`: 19 | 20 | ```toml 21 | [commands.pass] 22 | clear-hook = "~/.config/kbs2/hooks/pass-clear-notify" 23 | ``` 24 | -------------------------------------------------------------------------------- /contrib/hooks/pass-clear-notify/pass-clear-notify: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | [[ -n "${KBS2_HOOK}" ]] \ 4 | || { >&2 echo "Fatal: Not being run as a hook?"; exit 1; } 5 | 6 | system=$(uname -s) 7 | 8 | if [[ "${system}" == Linux ]]; then 9 | notify-send "kbs2 pass" "Cleared the clipboard" 10 | elif [[ "${system}" == Darwin ]]; then 11 | osascript -e 'display notification "Cleared the clipboard" with title "kbs2 pass"' 12 | else 13 | >&2 echo "[+] Unsupported system: ${system}" 14 | fi 15 | -------------------------------------------------------------------------------- /contrib/hooks/push-repo/README.md: -------------------------------------------------------------------------------- 1 | `push-repo` 2 | =========== 3 | 4 | `push-repo` is a `kbs2` hook that treats the `kbs2` store as a Git 5 | repository, committing and pushing any changes made to it whenever 6 | the hook is run. 7 | 8 | ## Setup 9 | 10 | To use `push-repo`, initialize a Git repository in your `kbs2` store: 11 | 12 | ```bash 13 | $ git init 14 | $ git remote add origin https://your-git-remote-here.git 15 | ``` 16 | 17 | ## Use 18 | 19 | `push-repo` should be configured as the `post-hook` on any command(s) that 20 | you regularly modify the store's state with. 21 | 22 | For example: 23 | 24 | ```toml 25 | [commands.new] 26 | post-hook = "~/.config/kbs2/hooks/push-repo" 27 | 28 | [commands.rm] 29 | post-hook = "~/.config/kbs2/hooks/push-repo" 30 | 31 | [commands.edit] 32 | post-hook = "~/.config/kbs2/hooks/push-repo" 33 | ``` 34 | 35 | Alternatively, you can set `push-repo` as the global post hook: 36 | 37 | ```toml 38 | post-hook = "~/.config/kbs2/hooks/push-repo" 39 | ``` 40 | 41 | Be aware, however, that doing this will make many `kbs2` actions slower. 42 | -------------------------------------------------------------------------------- /contrib/hooks/push-repo/push-repo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # push-repo: a kbs2 post-hook to sync the underlying git repo to the remote after any changes 4 | 5 | set -eo pipefail 6 | 7 | >&2 echo "[+] post-hook: ${0}" 8 | 9 | [[ -n "${KBS2_HOOK}" ]] \ 10 | || { >&2 echo "Fatal: Not being run as a hook?"; exit 1; } 11 | 12 | git add -A . 2>/dev/null 13 | git commit --no-gpg-sign -m "$(date)" 2>/dev/null 14 | git pull --no-verify-signatures --rebase --no-gpg-sign 2>/dev/null 15 | git push --no-signed origin master 2>/dev/null 16 | -------------------------------------------------------------------------------- /src/kbs2/agent.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fs; 3 | use std::io::{BufRead, BufReader, BufWriter, Read, Write}; 4 | use std::os::unix::net::{UnixListener, UnixStream}; 5 | use std::path::PathBuf; 6 | use std::process::{Command, Stdio}; 7 | use std::thread; 8 | use std::time::Duration; 9 | 10 | use anyhow::{anyhow, Context, Result}; 11 | use nix::unistd::Uid; 12 | use secrecy::{ExposeSecret, Secret, SecretString}; 13 | use serde::de::DeserializeOwned; 14 | use serde::{Deserialize, Serialize}; 15 | 16 | use crate::kbs2::backend::{Backend, RageLib}; 17 | 18 | /// The version of the agent protocol. 19 | const PROTOCOL_VERSION: u32 = 1; 20 | 21 | /// Represents the entire request message, including the protocol field. 22 | #[derive(Debug, Deserialize, PartialEq, Serialize)] 23 | struct Request { 24 | protocol: u32, 25 | body: RequestBody, 26 | } 27 | 28 | /// Represents the kinds of requests understood by the `kbs2` authentication agent. 29 | #[derive(Debug, Deserialize, PartialEq, Serialize)] 30 | #[serde(tag = "type", content = "body")] 31 | enum RequestBody { 32 | /// Unwrap a particular keyfile (second element) with a password (third element), identifying 33 | /// it in the agent with a particular public key (first element). 34 | UnwrapKey(String, String, String), 35 | 36 | /// Check whether a particular public key has an unwrapped keyfile in the agent. 37 | QueryUnwrappedKey(String), 38 | 39 | /// Get the actual unwrapped key, by public key. 40 | GetUnwrappedKey(String), 41 | 42 | /// Flush all keys from the agent. 43 | FlushKeys, 44 | 45 | /// Ask the agent to exit. 46 | Quit, 47 | } 48 | 49 | /// Represents the kinds of responses sent by the `kbs2` authentication agent. 50 | #[derive(Debug, Deserialize, PartialEq, Serialize)] 51 | #[serde(tag = "type", content = "body")] 52 | enum Response { 53 | /// A successful request, with some request-specific response data. 54 | Success(String), 55 | 56 | /// A failed request, of `FailureKind`. 57 | Failure(FailureKind), 58 | } 59 | 60 | /// Represents the kinds of failures encoded by a `kbs2` `Response`. 61 | #[derive(Debug, Deserialize, PartialEq, Serialize)] 62 | #[serde(tag = "type", content = "body")] 63 | enum FailureKind { 64 | /// The request failed because the client couldn't be authenticated. 65 | Auth, 66 | 67 | /// The request failed because one or more I/O operations failed. 68 | Io(String), 69 | 70 | /// The request failed because it was malformed. 71 | Malformed(String), 72 | 73 | /// The request failed because key unwrapping failed. 74 | Unwrap(String), 75 | 76 | /// The request failed because the agent and client don't speak the same protocol version. 77 | VersionMismatch(u32), 78 | 79 | /// The request failed because the requested query failed. 80 | Query, 81 | } 82 | 83 | /// A convenience trait for marshaling and unmarshaling `RequestBody`s and `Response`s 84 | /// through Rust's `Read` and `Write` traits. 85 | trait Message { 86 | fn read(reader: R) -> Result 87 | where 88 | Self: DeserializeOwned, 89 | { 90 | // NOTE(ww): This would be cleaner with a BufReader, but unsound: a BufReader 91 | // can buffer more than one line at once, causing us to silently drop client requests. 92 | // I don't think that would actually happen in this case (since each client sends exactly 93 | // one line before expecting a response), but it's one less thing to think about. 94 | // NOTE(ww): Safe unwrap: we only perform after checking `is_ok`, and we capture 95 | // the error by using `Result, _>` with `collect`. 96 | #[allow(clippy::unwrap_used)] 97 | let data: Result, _> = reader 98 | .bytes() 99 | .take_while(|b| b.is_ok() && *b.as_ref().unwrap() != b'\n') 100 | .collect(); 101 | let data = data?; 102 | let res = serde_json::from_slice(&data)?; 103 | 104 | Ok(res) 105 | } 106 | 107 | fn write(&self, mut writer: W) -> Result<()> 108 | where 109 | Self: Serialize, 110 | { 111 | serde_json::to_writer(&mut writer, &self)?; 112 | writer.write_all(b"\n")?; 113 | writer.flush()?; 114 | 115 | Ok(()) 116 | } 117 | } 118 | 119 | impl Message for Request {} 120 | impl Message for Response {} 121 | 122 | /// Represents the state in a running `kbs2` authentication agent. 123 | pub struct Agent { 124 | /// The local path to the Unix domain socket. 125 | agent_path: PathBuf, 126 | /// A map of public key => (keyfile path, unwrapped key material). 127 | unwrapped_keys: HashMap, 128 | /// Whether or not the agent intends to quit momentarily. 129 | quitting: bool, 130 | } 131 | 132 | impl Agent { 133 | /// Returns a unique, user-specific socket path that the authentication agent listens on. 134 | fn path() -> PathBuf { 135 | let mut agent_path = PathBuf::from("/tmp"); 136 | agent_path.push(format!("kbs2-agent-{}", whoami::username())); 137 | 138 | agent_path 139 | } 140 | 141 | /// Spawns a new agent as a daemon process, returning once the daemon 142 | /// is ready to begin serving clients. 143 | pub fn spawn() -> Result<()> { 144 | let agent_path = Self::path(); 145 | 146 | // If an agent appears to be running already, do nothing. 147 | if agent_path.exists() { 148 | log::debug!("agent seems to be running; not trying to spawn another"); 149 | return Ok(()); 150 | } 151 | 152 | log::debug!("agent isn't already running, attempting spawn"); 153 | 154 | // Sanity check: `kbs2` should never be run as root, and any difference between our 155 | // UID and EUID indicates some SUID-bit weirdness that we didn't expect and don't want. 156 | let (uid, euid) = (Uid::current(), Uid::effective()); 157 | if uid.is_root() || uid != euid { 158 | return Err(anyhow!( 159 | "unusual UID or UID/EUID pair found, refusing to spawn" 160 | )); 161 | } 162 | 163 | // NOTE(ww): Given the above, it *should* be safe to spawn based on the path returned by 164 | // `current_exe`: we know we aren't being tricked with any hardlink + SUID shenanigans. 165 | let kbs2 = std::env::current_exe().with_context(|| "failed to locate the kbs2 binary")?; 166 | 167 | // NOTE(ww): We could spawn the agent by forking and daemonizing, but that would require 168 | // at least one direct use of unsafe{} (for the fork itself), and potentially others. 169 | // This is a little simpler and requires less unsafety. 170 | let _ = Command::new(kbs2) 171 | .arg("agent") 172 | .stdin(Stdio::null()) 173 | .stdout(Stdio::null()) 174 | .stderr(Stdio::null()) 175 | .spawn()?; 176 | 177 | for attempt in 0..10 { 178 | log::debug!("waiting for agent, loop {}...", attempt); 179 | thread::sleep(Duration::from_millis(10)); 180 | if agent_path.exists() { 181 | return Ok(()); 182 | } 183 | } 184 | 185 | Err(anyhow!("agent spawn timeout exhausted")) 186 | } 187 | 188 | /// Initializes a new agent without accepting connections. 189 | pub fn new() -> Result { 190 | let agent_path = Self::path(); 191 | if agent_path.exists() { 192 | return Err(anyhow!( 193 | "an agent is already running or didn't exit cleanly" 194 | )); 195 | } 196 | 197 | #[allow(clippy::redundant_field_names)] 198 | Ok(Self { 199 | agent_path: agent_path, 200 | unwrapped_keys: HashMap::new(), 201 | quitting: false, 202 | }) 203 | } 204 | 205 | // TODO(ww): These can be replaced with the UnixStream.peer_cred API once it stabilizes: 206 | // https://doc.rust-lang.org/std/os/unix/net/struct.UnixStream.html#method.peer_cred 207 | #[cfg(any(target_os = "linux", target_os = "android",))] 208 | fn auth_client(&self, stream: &UnixStream) -> bool { 209 | use nix::sys::socket::getsockopt; 210 | use nix::sys::socket::sockopt::PeerCredentials; 211 | 212 | if let Ok(cred) = getsockopt(stream, PeerCredentials) { 213 | cred.uid() == Uid::effective().as_raw() 214 | } else { 215 | log::error!("getsockopt failed; treating as auth failure"); 216 | false 217 | } 218 | } 219 | 220 | #[cfg(any( 221 | target_os = "macos", 222 | target_os = "ios", 223 | target_os = "freebsd", 224 | target_os = "openbsd", 225 | target_os = "netbsd", 226 | target_os = "dragonfly", 227 | ))] 228 | fn auth_client(&self, stream: &UnixStream) -> bool { 229 | use std::os::unix::io::AsRawFd; 230 | 231 | use nix::unistd; 232 | 233 | if let Ok((peer_uid, _)) = unistd::getpeereid(stream) { 234 | peer_uid == Uid::effective() 235 | } else { 236 | log::error!("getpeereid failed; treating as auth failure"); 237 | false 238 | } 239 | } 240 | 241 | /// Handles an inner request payload, i.e. one of potentially several 242 | /// requests made during a client's connection. 243 | fn handle_request_body(&mut self, body: RequestBody) -> Response { 244 | match body { 245 | RequestBody::UnwrapKey(pubkey, keyfile, password) => { 246 | let password = Secret::new(password); 247 | // If the running agent is already tracking an unwrapped key for this 248 | // pubkey, return early with a success. 249 | #[allow(clippy::map_entry)] 250 | if self.unwrapped_keys.contains_key(&pubkey) { 251 | log::debug!( 252 | "client requested unwrap for already unwrapped keyfile: {}", 253 | keyfile 254 | ); 255 | Response::Success("OK; agent already has unwrapped key".into()) 256 | } else { 257 | match RageLib::unwrap_keyfile(&keyfile, password) { 258 | Ok(unwrapped_key) => { 259 | self.unwrapped_keys.insert(pubkey, (keyfile, unwrapped_key)); 260 | Response::Success("OK; unwrapped key ready".into()) 261 | } 262 | Err(e) => { 263 | log::error!("keyfile unwrap failed: {:?}", e); 264 | Response::Failure(FailureKind::Unwrap(e.to_string())) 265 | } 266 | } 267 | } 268 | } 269 | RequestBody::QueryUnwrappedKey(pubkey) => { 270 | if self.unwrapped_keys.contains_key(&pubkey) { 271 | Response::Success("OK".into()) 272 | } else { 273 | Response::Failure(FailureKind::Query) 274 | } 275 | } 276 | RequestBody::GetUnwrappedKey(pubkey) => { 277 | if let Some((_, unwrapped_key)) = self.unwrapped_keys.get(&pubkey) { 278 | log::debug!("successful key request for pubkey: {}", pubkey); 279 | Response::Success(unwrapped_key.expose_secret().into()) 280 | } else { 281 | log::error!("unknown pubkey requested: {}", &pubkey); 282 | Response::Failure(FailureKind::Query) 283 | } 284 | } 285 | RequestBody::FlushKeys => { 286 | self.unwrapped_keys.clear(); 287 | log::debug!("successfully flushed all unwrapped keys"); 288 | Response::Success("OK".into()) 289 | } 290 | RequestBody::Quit => { 291 | self.quitting = true; 292 | log::debug!("agent exit requested"); 293 | Response::Success("OK".into()) 294 | } 295 | } 296 | } 297 | 298 | /// Handles a single client connection. 299 | /// Individual clients may issue multiple requests in a single session. 300 | fn handle_client(&mut self, stream: UnixStream) { 301 | let reader = BufReader::new(&stream); 302 | let mut writer = BufWriter::new(&stream); 303 | 304 | if !self.auth_client(&stream) { 305 | log::warn!("client failed auth check"); 306 | // This can fail, but we don't care. 307 | let _ = Response::Failure(FailureKind::Auth).write(&mut writer); 308 | return; 309 | } 310 | 311 | for line in reader.lines() { 312 | let line = match line { 313 | Ok(line) => line, 314 | Err(e) => { 315 | log::error!("i/o error: {:?}", e); 316 | // This can fail, but we don't care. 317 | let _ = Response::Failure(FailureKind::Io(e.to_string())).write(&mut writer); 318 | return; 319 | } 320 | }; 321 | 322 | let req: Request = match serde_json::from_str(&line) { 323 | Ok(req) => req, 324 | Err(e) => { 325 | log::error!("malformed req: {:?}", e); 326 | // This can fail, but we don't care. 327 | let _ = 328 | Response::Failure(FailureKind::Malformed(e.to_string())).write(&mut writer); 329 | return; 330 | } 331 | }; 332 | 333 | if req.protocol != PROTOCOL_VERSION { 334 | let _ = Response::Failure(FailureKind::VersionMismatch(PROTOCOL_VERSION)) 335 | .write(&mut writer); 336 | return; 337 | } 338 | 339 | let resp = self.handle_request_body(req.body); 340 | 341 | // This can fail, but we don't care. 342 | let _ = resp.write(&mut writer); 343 | } 344 | } 345 | 346 | /// Run the `kbs2` authentication agent. 347 | /// 348 | /// The function does not return *unless* either an error occurs on agent startup *or* 349 | /// a client asks the agent to quit. 350 | pub fn run(&mut self) -> Result<()> { 351 | log::debug!("agent run requested"); 352 | 353 | let listener = UnixListener::bind(&self.agent_path)?; 354 | 355 | // NOTE(ww): This could spawn a separate thread for each incoming connection, but I see 356 | // no reason to do so: 357 | // 358 | // 1. The incoming queue already provides a synchronization mechanism, and we don't 359 | // expect a number of simultaneous clients that would come close to exceeding the 360 | // default queue length. Even if that were to happen, rejecting pending clients 361 | // is an acceptable error mode. 362 | // 2. Using separate threads here makes the rest of the code unnecessarily complicated: 363 | // each `Agent` becomes an `Arc>` to protect the underlying `HashMap`, 364 | // and makes actually quitting the agent with a `Quit` request more difficult than it 365 | // needs to be. 366 | for stream in listener.incoming() { 367 | match stream { 368 | Ok(stream) => { 369 | self.handle_client(stream); 370 | if self.quitting { 371 | break; 372 | } 373 | } 374 | Err(e) => { 375 | log::error!("connect error: {:?}", e); 376 | continue; 377 | } 378 | } 379 | } 380 | 381 | Ok(()) 382 | } 383 | } 384 | 385 | impl Drop for Agent { 386 | fn drop(&mut self) { 387 | log::debug!("agent teardown"); 388 | 389 | // NOTE(ww): We don't expect this to fail, but it's okay if it does: the agent gets dropped 390 | // at the very end of its lifecycle, meaning that an expect here is acceptable. 391 | #[allow(clippy::expect_used)] 392 | fs::remove_file(Agent::path()).expect("attempted to remove missing agent socket"); 393 | } 394 | } 395 | 396 | /// Represents a client to the `kbs2` authentication agent. 397 | /// 398 | /// Clients may send multiple requests and receive multiple responses while active. 399 | pub struct Client { 400 | stream: UnixStream, 401 | } 402 | 403 | impl Client { 404 | /// Create and return a new client, failing if connection to the agent fails. 405 | pub fn new() -> Result { 406 | log::debug!("creating a new agent client"); 407 | 408 | let stream = UnixStream::connect(Agent::path()) 409 | .with_context(|| "failed to connect to agent; is it running?")?; 410 | Ok(Self { stream }) 411 | } 412 | 413 | /// Issue the given request to the agent, returning the agent's `Response`. 414 | fn request(&self, body: RequestBody) -> Result { 415 | #[allow(clippy::redundant_field_names)] 416 | let req = Request { 417 | protocol: PROTOCOL_VERSION, 418 | body: body, 419 | }; 420 | req.write(&self.stream)?; 421 | let resp = Response::read(&self.stream)?; 422 | Ok(resp) 423 | } 424 | 425 | /// Instruct the agent to unwrap the given keyfile, using the given password. 426 | /// The keyfile path and its unwrapped contents are associated with the given pubkey. 427 | pub fn add_key(&self, pubkey: &str, keyfile: &str, password: SecretString) -> Result<()> { 428 | log::debug!("add_key: requesting that agent unwrap {}", keyfile); 429 | 430 | let body = RequestBody::UnwrapKey( 431 | pubkey.into(), 432 | keyfile.into(), 433 | password.expose_secret().into(), 434 | ); 435 | let resp = self.request(body)?; 436 | 437 | match resp { 438 | Response::Success(msg) => { 439 | log::debug!("agent reports success: {}", msg); 440 | Ok(()) 441 | } 442 | Response::Failure(kind) => Err(anyhow!("adding key to agent failed: {:?}", kind)), 443 | } 444 | } 445 | 446 | /// Ask the agent whether it has an unwrapped key for the given pubkey. 447 | pub fn query_key(&self, pubkey: &str) -> Result { 448 | log::debug!("query_key: asking whether agent has key for {}", pubkey); 449 | 450 | let body = RequestBody::QueryUnwrappedKey(pubkey.into()); 451 | let resp = self.request(body)?; 452 | 453 | match resp { 454 | Response::Success(_) => Ok(true), 455 | Response::Failure(FailureKind::Query) => Ok(false), 456 | Response::Failure(kind) => Err(anyhow!("querying key from agent failed: {:?}", kind)), 457 | } 458 | } 459 | 460 | /// Ask the agent for the unwrapped key material for the given pubkey. 461 | pub fn get_key(&self, pubkey: &str) -> Result { 462 | log::debug!("get_key: requesting unwrapped key for {}", pubkey); 463 | 464 | let body = RequestBody::GetUnwrappedKey(pubkey.into()); 465 | let resp = self.request(body)?; 466 | 467 | match resp { 468 | Response::Success(unwrapped_key) => Ok(unwrapped_key), 469 | Response::Failure(kind) => Err(anyhow!( 470 | "retrieving unwrapped key from agent failed: {:?}", 471 | kind 472 | )), 473 | } 474 | } 475 | 476 | /// Ask the agent to flush all of its unwrapped keys. 477 | pub fn flush_keys(&self) -> Result<()> { 478 | log::debug!("flush_keys: asking agent to forget all keys"); 479 | self.request(RequestBody::FlushKeys)?; 480 | Ok(()) 481 | } 482 | 483 | /// Ask the agent to quit gracefully. 484 | pub fn quit_agent(self) -> Result<()> { 485 | log::debug!("quit_agent: asking agent to exit gracefully"); 486 | self.request(RequestBody::Quit)?; 487 | Ok(()) 488 | } 489 | } 490 | -------------------------------------------------------------------------------- /src/kbs2/backend.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Read, Write}; 2 | use std::path::Path; 3 | 4 | use age::armor::{ArmoredReader, ArmoredWriter, Format}; 5 | use age::{Decryptor, IdentityFileEntry}; 6 | use anyhow::{anyhow, Context, Result}; 7 | use secrecy::{ExposeSecret, SecretString}; 8 | 9 | use crate::kbs2::agent; 10 | use crate::kbs2::config; 11 | use crate::kbs2::record::Record; 12 | use crate::kbs2::util; 13 | 14 | /// The maximum size of a wrapped key file, on disk. 15 | /// 16 | /// This is an **extremely** conservative maximum: actual plain-text formatted 17 | /// wrapped keys should never be more than a few hundred bytes. But we need some 18 | /// number of harden the I/O that the agent does, and a single page/4K seems reasonable. 19 | pub const MAX_WRAPPED_KEY_FILESIZE: u64 = 4096; 20 | 21 | /// Represents the operations that all age backends are capable of. 22 | pub trait Backend { 23 | /// Creates an age keypair, saving the private component to the given path. 24 | /// 25 | /// NOTE: The private component is written in an ASCII-armored format. 26 | fn create_keypair>(path: P) -> Result; 27 | 28 | /// Creates a wrapped age keypair, saving the encrypted private component to the 29 | /// given path. 30 | /// 31 | /// NOTE: Like `create_keypair`, this writes an ASCII-armored private component. 32 | fn create_wrapped_keypair>(path: P, password: SecretString) -> Result; 33 | 34 | /// Unwraps the given `keyfile` using `password`, returning the unwrapped contents. 35 | fn unwrap_keyfile>(keyfile: P, password: SecretString) -> Result; 36 | 37 | /// Wraps the given `key` using the given `password`, returning the wrapped result. 38 | fn wrap_key(key: SecretString, password: SecretString) -> Result>; 39 | 40 | /// Rewraps the given keyfile in place, decrypting it with the `old` password 41 | /// and re-encrypting it with the `new` password. 42 | /// 43 | /// NOTE: This function does *not* make a backup of the original keyfile. 44 | fn rewrap_keyfile>(path: P, old: SecretString, new: SecretString) -> Result<()>; 45 | 46 | /// Encrypts the given record, returning it as an ASCII-armored string. 47 | fn encrypt(&self, record: &Record) -> Result; 48 | 49 | /// Decrypts the given ASCII-armored string, returning it as a Record. 50 | fn decrypt(&self, encrypted: &str) -> Result; 51 | } 52 | 53 | /// Encapsulates the age crate (i.e., the `rage` CLI's backing library). 54 | pub struct RageLib { 55 | pub pubkey: age::x25519::Recipient, 56 | pub identities: Vec, 57 | } 58 | 59 | impl RageLib { 60 | pub fn new(config: &config::Config) -> Result { 61 | let pubkey = config 62 | .public_key 63 | .parse::() 64 | .map_err(|e| anyhow!("unable to parse public key (backend reports: {:?})", e))?; 65 | 66 | let identities = if config.wrapped { 67 | log::debug!("config specifies a wrapped key"); 68 | 69 | let client = agent::Client::new().with_context(|| "failed to connect to kbs2 agent")?; 70 | 71 | if !client.query_key(&config.public_key)? { 72 | client.add_key( 73 | &config.public_key, 74 | &config.keyfile, 75 | util::get_password(None, &config.pinentry)?, 76 | )?; 77 | } 78 | 79 | let unwrapped_key = client 80 | .get_key(&config.public_key) 81 | .with_context(|| format!("agent has no unwrapped key for {}", config.keyfile))?; 82 | 83 | log::debug!("parsing unwrapped key"); 84 | age::IdentityFile::from_buffer(unwrapped_key.as_bytes())? 85 | } else { 86 | age::IdentityFile::from_file(config.keyfile.clone())? 87 | } 88 | .into_identities(); 89 | log::debug!("successfully parsed a private key!"); 90 | 91 | if identities.len() != 1 { 92 | return Err(anyhow!( 93 | "expected exactly one private key in the keyfile, but got {}", 94 | identities.len() 95 | )); 96 | } 97 | 98 | let identities = identities 99 | .into_iter() 100 | .map(|i| match i { 101 | // NOTE(ww): We're not using the plugin feature of the `age` crate, 102 | // so this is the only variant. 103 | IdentityFileEntry::Native(i) => i, 104 | }) 105 | .collect(); 106 | 107 | Ok(RageLib { pubkey, identities }) 108 | } 109 | } 110 | 111 | impl Backend for RageLib { 112 | fn create_keypair>(path: P) -> Result { 113 | let keypair = age::x25519::Identity::generate(); 114 | 115 | std::fs::write(path, keypair.to_string().expose_secret())?; 116 | 117 | Ok(keypair.to_public().to_string()) 118 | } 119 | 120 | fn create_wrapped_keypair>(path: P, password: SecretString) -> Result { 121 | let keypair = age::x25519::Identity::generate(); 122 | let wrapped_key = Self::wrap_key(keypair.to_string(), password)?; 123 | std::fs::write(path, wrapped_key)?; 124 | 125 | Ok(keypair.to_public().to_string()) 126 | } 127 | 128 | fn unwrap_keyfile>(keyfile: P, password: SecretString) -> Result { 129 | let wrapped_key = util::read_guarded(&keyfile, MAX_WRAPPED_KEY_FILESIZE)?; 130 | 131 | // Create a new decryptor for the wrapped key. 132 | let decryptor = match Decryptor::new(ArmoredReader::new(wrapped_key.as_slice())) { 133 | Ok(Decryptor::Passphrase(d)) => d, 134 | Ok(_) => { 135 | return Err(anyhow!( 136 | "key unwrap failed; not a password-wrapped keyfile?" 137 | )); 138 | } 139 | Err(e) => { 140 | return Err(anyhow!( 141 | "unable to load private key (backend reports: {:?})", 142 | e 143 | )); 144 | } 145 | }; 146 | 147 | // ...and decrypt (i.e., unwrap) using the master password. 148 | log::debug!("beginning key unwrap..."); 149 | let mut unwrapped_key = String::new(); 150 | 151 | // NOTE(ww): A work factor of 22 is an educated guess here; rage has generated messages 152 | // that have needed 17 and 18 before, so this should (hopefully) give us some 153 | // breathing room. 154 | decryptor 155 | .decrypt(&password, Some(22)) 156 | .map_err(|e| anyhow!("unable to decrypt (backend reports: {:?})", e)) 157 | .and_then(|mut r| { 158 | r.read_to_string(&mut unwrapped_key) 159 | .map_err(|_| anyhow!("i/o error while decrypting")) 160 | })?; 161 | log::debug!("finished key unwrap!"); 162 | 163 | Ok(SecretString::new(unwrapped_key)) 164 | } 165 | 166 | fn wrap_key(key: SecretString, password: SecretString) -> Result> { 167 | let encryptor = age::Encryptor::with_user_passphrase(password); 168 | 169 | let mut wrapped_key = vec![]; 170 | let mut writer = encryptor.wrap_output(ArmoredWriter::wrap_output( 171 | &mut wrapped_key, 172 | Format::AsciiArmor, 173 | )?)?; 174 | writer.write_all(key.expose_secret().as_bytes())?; 175 | writer.finish().and_then(|armor| armor.finish())?; 176 | 177 | Ok(wrapped_key) 178 | } 179 | 180 | fn rewrap_keyfile>( 181 | keyfile: P, 182 | old: SecretString, 183 | new: SecretString, 184 | ) -> Result<()> { 185 | let unwrapped_key = Self::unwrap_keyfile(&keyfile, old)?; 186 | let rewrapped_key = Self::wrap_key(unwrapped_key, new)?; 187 | 188 | std::fs::write(&keyfile, rewrapped_key)?; 189 | Ok(()) 190 | } 191 | 192 | fn encrypt(&self, record: &Record) -> Result { 193 | #[allow(clippy::unwrap_used)] 194 | let encryptor = 195 | age::Encryptor::with_recipients(vec![Box::new(self.pubkey.clone())]).unwrap(); 196 | let mut encrypted = vec![]; 197 | let mut writer = encryptor 198 | .wrap_output(ArmoredWriter::wrap_output( 199 | &mut encrypted, 200 | Format::AsciiArmor, 201 | )?) 202 | .map_err(|e| anyhow!("wrap_output failed (backend report: {:?})", e))?; 203 | writer.write_all(serde_json::to_string(record)?.as_bytes())?; 204 | writer.finish().and_then(|armor| armor.finish())?; 205 | 206 | Ok(String::from_utf8(encrypted)?) 207 | } 208 | 209 | fn decrypt(&self, encrypted: &str) -> Result { 210 | let decryptor = match age::Decryptor::new(ArmoredReader::new(encrypted.as_bytes())) 211 | .map_err(|e| anyhow!("unable to load private key (backend reports: {:?})", e))? 212 | { 213 | age::Decryptor::Recipients(d) => d, 214 | // NOTE(ww): we should be fully unwrapped (if we were wrapped to begin with) 215 | // in this context, so all other kinds of keys should be unreachable here. 216 | _ => unreachable!(), 217 | }; 218 | 219 | let mut decrypted = String::new(); 220 | 221 | decryptor 222 | .decrypt(self.identities.iter().map(|i| i as &dyn age::Identity)) 223 | .map_err(|e| anyhow!("unable to decrypt (backend reports: {:?})", e)) 224 | .and_then(|mut r| { 225 | r.read_to_string(&mut decrypted) 226 | .map_err(|e| anyhow!("i/o error while decrypting: {:?}", e)) 227 | })?; 228 | 229 | Ok(serde_json::from_str(&decrypted)?) 230 | } 231 | } 232 | 233 | #[cfg(test)] 234 | mod tests { 235 | use super::*; 236 | use crate::kbs2::record::{LoginFields, RecordBody}; 237 | 238 | fn dummy_login() -> Record { 239 | Record::new( 240 | "dummy", 241 | RecordBody::Login(LoginFields { 242 | username: "foobar".into(), 243 | password: "bazqux".into(), 244 | }), 245 | ) 246 | } 247 | 248 | fn ragelib_backend() -> RageLib { 249 | let key = age::x25519::Identity::generate(); 250 | 251 | RageLib { 252 | pubkey: key.to_public(), 253 | identities: vec![key], 254 | } 255 | } 256 | 257 | fn ragelib_backend_bad_keypair() -> RageLib { 258 | let key1 = age::x25519::Identity::generate(); 259 | let key2 = age::x25519::Identity::generate(); 260 | 261 | RageLib { 262 | pubkey: key1.to_public(), 263 | identities: vec![key2], 264 | } 265 | } 266 | 267 | #[test] 268 | fn test_ragelib_create_keypair() { 269 | let keyfile = tempfile::NamedTempFile::new().unwrap(); 270 | 271 | assert!(RageLib::create_keypair(&keyfile).is_ok()); 272 | } 273 | 274 | #[test] 275 | fn test_ragelib_create_wrapped_keypair() { 276 | let keyfile = tempfile::NamedTempFile::new().unwrap(); 277 | 278 | // Creating a wrapped keypair with a particular password should succeed. 279 | assert!(RageLib::create_wrapped_keypair( 280 | &keyfile, 281 | SecretString::new("weakpassword".into()) 282 | ) 283 | .is_ok()); 284 | 285 | // Unwrapping the keyfile using the same password should succeed. 286 | assert!( 287 | RageLib::unwrap_keyfile(&keyfile, SecretString::new("weakpassword".into())).is_ok() 288 | ); 289 | } 290 | 291 | #[test] 292 | fn test_ragelib_rewrap_keyfile() { 293 | let keyfile = tempfile::NamedTempFile::new().unwrap(); 294 | 295 | RageLib::create_wrapped_keypair(&keyfile, SecretString::new("weakpassword".into())) 296 | .unwrap(); 297 | 298 | let wrapped_key_a = std::fs::read(&keyfile).unwrap(); 299 | let unwrapped_key_a = 300 | RageLib::unwrap_keyfile(&keyfile, SecretString::new("weakpassword".into())).unwrap(); 301 | 302 | // Changing the password on a wrapped keyfile should succeed. 303 | assert!(RageLib::rewrap_keyfile( 304 | &keyfile, 305 | SecretString::new("weakpassword".into()), 306 | SecretString::new("stillweak".into()), 307 | ) 308 | .is_ok()); 309 | 310 | let wrapped_key_b = std::fs::read(&keyfile).unwrap(); 311 | let unwrapped_key_b = 312 | RageLib::unwrap_keyfile(&keyfile, SecretString::new("stillweak".into())).unwrap(); 313 | 314 | // The wrapped envelopes should not be equal, since the password has changed. 315 | assert_ne!(wrapped_key_a, wrapped_key_b); 316 | 317 | // However, the wrapped key itself should be preserved. 318 | assert_eq!( 319 | unwrapped_key_a.expose_secret(), 320 | unwrapped_key_b.expose_secret() 321 | ); 322 | } 323 | 324 | #[test] 325 | fn test_ragelib_encrypt() { 326 | { 327 | let backend = ragelib_backend(); 328 | let record = dummy_login(); 329 | assert!(backend.encrypt(&record).is_ok()); 330 | } 331 | 332 | // TODO: Test RageLib::encrypt failure modes. 333 | } 334 | 335 | #[test] 336 | fn test_ragelib_decrypt() { 337 | { 338 | let backend = ragelib_backend(); 339 | let record = dummy_login(); 340 | 341 | let encrypted = backend.encrypt(&record).unwrap(); 342 | let decrypted = backend.decrypt(&encrypted).unwrap(); 343 | 344 | assert_eq!(record, decrypted); 345 | } 346 | 347 | { 348 | let backend = ragelib_backend_bad_keypair(); 349 | let record = dummy_login(); 350 | 351 | let encrypted = backend.encrypt(&record).unwrap(); 352 | let err = backend.decrypt(&encrypted).unwrap_err(); 353 | 354 | assert_eq!( 355 | err.to_string(), 356 | "unable to decrypt (backend reports: NoMatchingKeys)" 357 | ); 358 | } 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /src/kbs2/command.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryInto; 2 | use std::env; 3 | use std::fmt::Write as _; 4 | use std::io::{self, stdin, IsTerminal, Read, Seek, Write}; 5 | use std::path::{Path, PathBuf}; 6 | use std::process; 7 | 8 | use anyhow::{anyhow, Result}; 9 | use arboard::Clipboard; 10 | use clap::ArgMatches; 11 | use daemonize::Daemonize; 12 | use inquire::Confirm; 13 | use nix::unistd::{fork, ForkResult}; 14 | use secrecy::{ExposeSecret, Secret}; 15 | 16 | use crate::kbs2::agent; 17 | use crate::kbs2::backend::{self, Backend}; 18 | use crate::kbs2::config::{self, Pinentry}; 19 | use crate::kbs2::generator::Generator; 20 | use crate::kbs2::input::Input; 21 | use crate::kbs2::record::{ 22 | self, EnvironmentFields, LoginFields, Record, RecordBody, UnstructuredFields, 23 | }; 24 | use crate::kbs2::session::Session; 25 | use crate::kbs2::util; 26 | 27 | /// Implements the `kbs2 init` command. 28 | pub fn init(matches: &ArgMatches, config_dir: &Path) -> Result<()> { 29 | log::debug!("initializing a new config"); 30 | 31 | #[allow(clippy::unwrap_used)] 32 | if config_dir.join(config::CONFIG_BASENAME).exists() 33 | && !*matches.get_one::("force").unwrap() 34 | { 35 | return Err(anyhow!( 36 | "refusing to overwrite your current config without --force" 37 | )); 38 | } 39 | 40 | #[allow(clippy::unwrap_used)] 41 | let store_dir = matches.get_one::("store-dir").unwrap().as_path(); 42 | 43 | // Warn, but don't fail, if the store directory is already present. 44 | if store_dir.exists() { 45 | util::warn("Requested store directory already exists"); 46 | } 47 | 48 | #[allow(clippy::unwrap_used)] 49 | let password = if !*matches.get_one::("insecure-not-wrapped").unwrap() { 50 | Some(util::get_password(None, Pinentry::default())?) 51 | } else { 52 | None 53 | }; 54 | 55 | config::initialize(&config_dir, &store_dir, password) 56 | } 57 | 58 | /// Implements the `kbs2 agent` command (and subcommands). 59 | pub fn agent(matches: &ArgMatches, config: &config::Config) -> Result<()> { 60 | log::debug!("agent subcommand dispatch"); 61 | 62 | // No subcommand: run the agent itself 63 | if matches.subcommand().is_none() { 64 | let mut agent = agent::Agent::new()?; 65 | #[allow(clippy::unwrap_used)] 66 | if !matches.get_one::("foreground").unwrap() { 67 | Daemonize::new().start()?; 68 | } 69 | agent.run()?; 70 | return Ok(()); 71 | } 72 | 73 | match matches.subcommand() { 74 | Some(("flush", matches)) => agent_flush(matches), 75 | Some(("query", matches)) => agent_query(matches, config), 76 | Some(("unwrap", matches)) => agent_unwrap(matches, config), 77 | _ => unreachable!(), 78 | } 79 | } 80 | 81 | /// Implements the `kbs2 agent flush` subcommand. 82 | fn agent_flush(matches: &ArgMatches) -> Result<()> { 83 | log::debug!("asking the agent to flush all keys"); 84 | 85 | let client = agent::Client::new()?; 86 | client.flush_keys()?; 87 | 88 | #[allow(clippy::unwrap_used)] 89 | if *matches.get_one::("quit").unwrap() { 90 | client.quit_agent()?; 91 | } 92 | 93 | Ok(()) 94 | } 95 | 96 | /// Implements the `kbs2 agent query` subcommand. 97 | fn agent_query(_matches: &ArgMatches, config: &config::Config) -> Result<()> { 98 | log::debug!("querying the agent for a key's existence"); 99 | 100 | // It doesn't make sense to query the agent for keypairs that the agent 101 | // doesn't manage. Use a specific code to signal this case. 102 | if !config.wrapped { 103 | std::process::exit(2); 104 | } 105 | 106 | // Don't allow client creation to fail the normal way: if we can't create 107 | // a client for whatever reason (e.g., the agent isn't running), exit 108 | // with a specific code to signal our state to the user. 109 | let client = agent::Client::new().unwrap_or_else(|_| std::process::exit(3)); 110 | if !client.query_key(&config.public_key)? { 111 | std::process::exit(1); 112 | } 113 | 114 | Ok(()) 115 | } 116 | 117 | /// Implements the `kbs2 agent unwrap` subcommand. 118 | fn agent_unwrap(_matches: &ArgMatches, config: &config::Config) -> Result<()> { 119 | log::debug!("asking the agent to unwrap a key"); 120 | 121 | // Bare keys are loaded directly from their `keyfile`. 122 | if !config.wrapped { 123 | return Err(anyhow!("config specifies a bare key; nothing to do")); 124 | } 125 | 126 | let client = agent::Client::new()?; 127 | if client.query_key(&config.public_key)? { 128 | println!("kbs2 agent already has this key; ignoring."); 129 | return Ok(()); 130 | } 131 | 132 | let password = util::get_password(None, &config.pinentry)?; 133 | client.add_key(&config.public_key, &config.keyfile, password)?; 134 | 135 | Ok(()) 136 | } 137 | 138 | /// Implements the `kbs2 new` command. 139 | pub fn new(matches: &ArgMatches, config: &config::Config) -> Result<()> { 140 | log::debug!("creating a new record"); 141 | 142 | let session: Session = config.try_into()?; 143 | 144 | if let Some(pre_hook) = &session.config.commands.new.pre_hook { 145 | log::debug!("pre-hook: {}", pre_hook); 146 | session.config.call_hook(pre_hook, &[])?; 147 | } 148 | 149 | #[allow(clippy::unwrap_used)] 150 | let label = matches.get_one::("label").unwrap(); 151 | 152 | #[allow(clippy::unwrap_used)] 153 | if session.has_record(label) && !matches.get_one::("force").unwrap() { 154 | return Err(anyhow!("refusing to overwrite a record without --force")); 155 | } 156 | 157 | let config = session.config.with_matches(matches); 158 | 159 | #[allow(clippy::unwrap_used)] 160 | let record = match matches 161 | .get_one::("kind") 162 | .map(AsRef::as_ref) 163 | .unwrap() 164 | { 165 | "login" => Record::new(label, LoginFields::input(&config)?), 166 | "environment" => Record::new(label, EnvironmentFields::input(&config)?), 167 | "unstructured" => Record::new(label, UnstructuredFields::input(&config)?), 168 | _ => unreachable!(), 169 | }; 170 | 171 | session.add_record(&record)?; 172 | 173 | if let Some(post_hook) = &session.config.commands.new.post_hook { 174 | log::debug!("post-hook: {}", post_hook); 175 | session.config.call_hook(post_hook, &[label])?; 176 | } 177 | 178 | Ok(()) 179 | } 180 | 181 | /// Implements the `kbs2 list` command. 182 | pub fn list(matches: &ArgMatches, config: &config::Config) -> Result<()> { 183 | log::debug!("listing records"); 184 | 185 | let session: Session = config.try_into()?; 186 | 187 | #[allow(clippy::unwrap_used)] 188 | let (details, filter_kind) = ( 189 | *matches.get_one::("details").unwrap(), 190 | matches.contains_id("kind"), 191 | ); 192 | 193 | for label in session.record_labels()? { 194 | let mut display = String::new(); 195 | 196 | if details || filter_kind { 197 | let record = session.get_record(&label)?; 198 | 199 | if filter_kind { 200 | #[allow(clippy::unwrap_used)] 201 | let kind = matches.get_one::("kind").unwrap(); 202 | if &record.body.to_string() != kind { 203 | continue; 204 | } 205 | } 206 | 207 | display.push_str(&label); 208 | 209 | if details { 210 | write!(display, " {} {}", record.body, record.timestamp)?; 211 | } 212 | } else { 213 | display.push_str(&label); 214 | } 215 | 216 | println!("{display}"); 217 | } 218 | 219 | Ok(()) 220 | } 221 | 222 | /// Implements the `kbs2 rm` command. 223 | pub fn rm(matches: &ArgMatches, config: &config::Config) -> Result<()> { 224 | log::debug!("removing a record"); 225 | 226 | let session: Session = config.try_into()?; 227 | 228 | #[allow(clippy::unwrap_used)] 229 | let labels: Vec<_> = matches 230 | .get_many::("label") 231 | .unwrap() 232 | .map(AsRef::as_ref) 233 | .collect(); 234 | 235 | for label in &labels { 236 | session.delete_record(label)?; 237 | } 238 | 239 | if let Some(post_hook) = &session.config.commands.rm.post_hook { 240 | log::debug!("post-hook: {}", post_hook); 241 | session.config.call_hook(post_hook, &labels)?; 242 | } 243 | 244 | Ok(()) 245 | } 246 | 247 | /// Implements the `kbs2 rename` command. 248 | pub fn rename(matches: &ArgMatches, config: &config::Config) -> Result<()> { 249 | log::debug!("renaming a record"); 250 | 251 | let session: Session = config.try_into()?; 252 | 253 | #[allow(clippy::unwrap_used)] 254 | let old_label: &str = matches.get_one::("old-label").unwrap(); 255 | 256 | #[allow(clippy::unwrap_used)] 257 | let new_label: &str = matches.get_one::("new-label").unwrap(); 258 | 259 | #[allow(clippy::unwrap_used)] 260 | if session.has_record(new_label) && !matches.get_one::("force").unwrap() { 261 | return Err(anyhow!("refusing to overwrite a record without --force")); 262 | } 263 | 264 | session.rename_record(old_label, new_label)?; 265 | 266 | if let Some(post_hook) = &session.config.commands.rename.post_hook { 267 | log::debug!("post-hook: {}", post_hook); 268 | session 269 | .config 270 | .call_hook(post_hook, &[old_label, new_label])?; 271 | } 272 | 273 | Ok(()) 274 | } 275 | 276 | /// Implements the `kbs2 dump` command. 277 | pub fn dump(matches: &ArgMatches, config: &config::Config) -> Result<()> { 278 | log::debug!("dumping a record"); 279 | 280 | let session: Session = config.try_into()?; 281 | 282 | #[allow(clippy::unwrap_used)] 283 | let labels: Vec<_> = matches.get_many::("label").unwrap().collect(); 284 | 285 | for label in labels { 286 | let record = session.get_record(label)?; 287 | 288 | #[allow(clippy::unwrap_used)] 289 | if *matches.get_one::("json").unwrap() { 290 | println!("{}", serde_json::to_string(&record)?); 291 | } else { 292 | println!("Label {}\nKind {}", label, record.body); 293 | 294 | match record.body { 295 | RecordBody::Login(l) => { 296 | println!("Username {}\nPassword {}", l.username, l.password) 297 | } 298 | RecordBody::Environment(e) => { 299 | println!("Variable {}\nValue {}", e.variable, e.value) 300 | } 301 | RecordBody::Unstructured(u) => println!("Contents {}", u.contents), 302 | } 303 | } 304 | } 305 | 306 | Ok(()) 307 | } 308 | 309 | /// Implements the `kbs2 pass` command. 310 | pub fn pass(matches: &ArgMatches, config: &config::Config) -> Result<()> { 311 | log::debug!("getting a login's password"); 312 | 313 | let session: Session = config.try_into()?; 314 | 315 | if let Some(pre_hook) = &session.config.commands.pass.pre_hook { 316 | log::debug!("pre-hook: {}", pre_hook); 317 | session.config.call_hook(pre_hook, &[])?; 318 | } 319 | 320 | #[allow(clippy::unwrap_used)] 321 | let label = matches.get_one::("label").unwrap(); 322 | let record = session.get_record(label)?; 323 | 324 | let login = match record.body { 325 | RecordBody::Login(l) => l, 326 | _ => return Err(anyhow!("not a login record: {}", label)), 327 | }; 328 | 329 | let password = login.password; 330 | 331 | #[allow(clippy::unwrap_used)] 332 | if *matches.get_one::("clipboard").unwrap() { 333 | // NOTE(ww): fork() is unsafe in multithreaded programs where the child calls 334 | // non async-signal-safe functions. kbs2 is single threaded, so this usage is fine. 335 | unsafe { 336 | match fork() { 337 | Ok(ForkResult::Child) => { 338 | clip(password, &session)?; 339 | } 340 | Err(_) => return Err(anyhow!("clipboard fork failed")), 341 | _ => {} 342 | } 343 | } 344 | } else if !stdin().is_terminal() { 345 | print!("{password}"); 346 | } else { 347 | println!("{password}"); 348 | } 349 | 350 | if let Some(post_hook) = &session.config.commands.pass.post_hook { 351 | log::debug!("post-hook: {}", post_hook); 352 | session.config.call_hook(post_hook, &[])?; 353 | } 354 | 355 | Ok(()) 356 | } 357 | 358 | #[doc(hidden)] 359 | fn clip(password: String, session: &Session) -> Result<()> { 360 | let clipboard_duration = session.config.commands.pass.clipboard_duration; 361 | let clear_after = session.config.commands.pass.clear_after; 362 | 363 | let mut clipboard = Clipboard::new()?; 364 | clipboard.set_text(&password)?; 365 | 366 | std::thread::sleep(std::time::Duration::from_secs(clipboard_duration)); 367 | 368 | if clear_after { 369 | clipboard.clear()?; 370 | 371 | if let Some(clear_hook) = &session.config.commands.pass.clear_hook { 372 | log::debug!("clear-hook: {}", clear_hook); 373 | session.config.call_hook(clear_hook, &[])?; 374 | } 375 | } 376 | 377 | Ok(()) 378 | } 379 | 380 | /// Implements the `kbs2 env` command. 381 | pub fn env(matches: &ArgMatches, config: &config::Config) -> Result<()> { 382 | log::debug!("getting a environment variable"); 383 | 384 | let session: Session = config.try_into()?; 385 | 386 | #[allow(clippy::unwrap_used)] 387 | let label = matches.get_one::("label").unwrap(); 388 | let record = session.get_record(label)?; 389 | 390 | let environment = match record.body { 391 | RecordBody::Environment(e) => e, 392 | _ => return Err(anyhow!("not an environment record: {}", label)), 393 | }; 394 | 395 | #[allow(clippy::unwrap_used)] 396 | if *matches.get_one::("value-only").unwrap() { 397 | println!("{}", environment.value); 398 | } else if *matches.get_one::("no-export").unwrap() { 399 | println!("{}={}", environment.variable, environment.value); 400 | } else { 401 | println!("export {}={}", environment.variable, environment.value); 402 | } 403 | 404 | Ok(()) 405 | } 406 | 407 | /// Implements the `kbs2 edit` command. 408 | pub fn edit(matches: &ArgMatches, config: &config::Config) -> Result<()> { 409 | log::debug!("editing a record"); 410 | 411 | let session: Session = config.try_into()?; 412 | 413 | let editor = match session 414 | .config 415 | .commands 416 | .edit 417 | .editor 418 | .as_ref() 419 | .cloned() 420 | .or_else(|| env::var("EDITOR").ok()) 421 | { 422 | Some(editor) => editor, 423 | None => return Err(anyhow!("no editor configured to edit with")), 424 | }; 425 | 426 | let (editor, editor_args) = util::parse_and_split_args(&editor)?; 427 | 428 | log::debug!("editor: {}, args: {:?}", editor, editor_args); 429 | 430 | #[allow(clippy::unwrap_used)] 431 | let label = matches.get_one::("label").unwrap(); 432 | let record = session.get_record(label)?; 433 | 434 | let mut file = tempfile::NamedTempFile::new()?; 435 | file.write_all(&serde_json::to_vec_pretty(&record)?)?; 436 | 437 | if !process::Command::new(&editor) 438 | .args(&editor_args) 439 | .arg(file.path()) 440 | .status() 441 | .is_ok_and(|o| o.success()) 442 | { 443 | return Err(anyhow!("failed to run the editor")); 444 | } 445 | 446 | // Rewind, pull the changed contents, deserialize back into a record. 447 | file.rewind()?; 448 | let mut record_contents = vec![]; 449 | file.read_to_end(&mut record_contents)?; 450 | 451 | let mut record = serde_json::from_slice::(&record_contents)?; 452 | 453 | // Users can't modify these fields, at least not with `kbs2 edit`. 454 | record.label = label.into(); 455 | record.timestamp = util::current_timestamp(); 456 | 457 | session.add_record(&record)?; 458 | 459 | if let Some(post_hook) = &session.config.commands.edit.post_hook { 460 | log::debug!("post-hook: {}", post_hook); 461 | session.config.call_hook(post_hook, &[])?; 462 | } 463 | 464 | Ok(()) 465 | } 466 | 467 | /// Implements the `kbs2 generate` command. 468 | pub fn generate(matches: &ArgMatches, config: &config::Config) -> Result<()> { 469 | let generator = { 470 | #[allow(clippy::unwrap_used)] 471 | let generator_name = matches.get_one::("generator").unwrap(); 472 | match config.generator(generator_name) { 473 | Some(generator) => generator, 474 | None => { 475 | return Err(anyhow!( 476 | "couldn't find a generator named {}", 477 | generator_name 478 | )) 479 | } 480 | } 481 | }; 482 | 483 | println!("{}", generator.secret()?); 484 | 485 | Ok(()) 486 | } 487 | 488 | /// Implements the `kbs2 rewrap` command. 489 | pub fn rewrap(matches: &ArgMatches, config: &config::Config) -> Result<()> { 490 | log::debug!("attempting key rewrap"); 491 | 492 | if !config.wrapped { 493 | return Err(anyhow!("config specifies a bare key; nothing to rewrap")); 494 | } 495 | 496 | #[allow(clippy::unwrap_used)] 497 | if !*matches.get_one::("no-backup").unwrap() { 498 | let keyfile_backup: PathBuf = format!("{}.old", &config.keyfile).into(); 499 | 500 | #[allow(clippy::unwrap_used)] 501 | if keyfile_backup.exists() && !*matches.get_one::("force").unwrap() { 502 | return Err(anyhow!( 503 | "refusing to overwrite a previous key backup without --force" 504 | )); 505 | } 506 | 507 | std::fs::copy(&config.keyfile, &keyfile_backup)?; 508 | println!("Backup of the OLD wrapped keyfile saved to: {keyfile_backup:?}"); 509 | } 510 | 511 | let old = util::get_password(Some("OLD master password: "), &config.pinentry)?; 512 | let new = util::get_password(Some("NEW master password: "), &config.pinentry)?; 513 | 514 | backend::RageLib::rewrap_keyfile(&config.keyfile, old, new) 515 | } 516 | 517 | /// Implements the `kbs2 rekey` command. 518 | pub fn rekey(matches: &ArgMatches, config: &config::Config) -> Result<()> { 519 | log::debug!("attempting to rekey the store"); 520 | 521 | // This is an artificial limitation; bare keys should never be used outside of testing, 522 | // so support for them is unnecessary here. 523 | if !config.wrapped { 524 | return Err(anyhow!("rekeying is only supported on wrapped keys")); 525 | } 526 | 527 | let session: Session = config.try_into()?; 528 | 529 | println!( 530 | "This subcommand REKEYS your entire store ({}) and REWRITES your config", 531 | session.config.store 532 | ); 533 | 534 | if !Confirm::new("Are you SURE you want to continue?") 535 | .with_default(false) 536 | .with_help_message("Be certain! If you are not certain, press [enter] to do nothing.") 537 | .prompt()? 538 | { 539 | return Ok(()); 540 | } 541 | 542 | #[allow(clippy::unwrap_used)] 543 | if !*matches.get_one::("no-backup").unwrap() { 544 | // First, back up the keyfile. 545 | let keyfile_backup: PathBuf = format!("{}.old", &config.keyfile).into(); 546 | if keyfile_backup.exists() { 547 | return Err(anyhow!( 548 | "refusing to overwrite a previous key backup during rekeying; resolve manually" 549 | )); 550 | } 551 | 552 | std::fs::copy(&config.keyfile, &keyfile_backup)?; 553 | println!("Backup of the OLD wrapped keyfile saved to: {keyfile_backup:?}"); 554 | 555 | // Next, the config itself. 556 | let config_backup: PathBuf = 557 | Path::new(&config.config_dir).join(format!("{}.old", config::CONFIG_BASENAME)); 558 | if config_backup.exists() { 559 | return Err(anyhow!( 560 | "refusing to overwrite a previous config backup during rekeying; resolve manually" 561 | )); 562 | } 563 | 564 | std::fs::copy( 565 | Path::new(&config.config_dir).join(config::CONFIG_BASENAME), 566 | &config_backup, 567 | )?; 568 | println!("Backup of the OLD config saved to: {config_backup:?}"); 569 | 570 | // Finally, every record in the store. 571 | let store_backup: PathBuf = format!("{}.old", &config.store).into(); 572 | if store_backup.exists() { 573 | return Err(anyhow!( 574 | "refusing to overwrite a previous store backup during rekeying; resolve manually" 575 | )); 576 | } 577 | 578 | std::fs::create_dir_all(&store_backup)?; 579 | for label in session.record_labels()? { 580 | std::fs::copy( 581 | Path::new(&config.store).join(&label), 582 | store_backup.join(&label), 583 | )?; 584 | } 585 | println!("Backup of the OLD store saved to: {:?}", &store_backup); 586 | } 587 | 588 | // Decrypt and collect all records. 589 | let records: Vec> = { 590 | let records: Result> = session 591 | .record_labels()? 592 | .iter() 593 | .map(|l| session.get_record(l)) 594 | .collect(); 595 | 596 | records?.into_iter().map(Secret::new).collect() 597 | }; 598 | 599 | // Get a new master password. 600 | let new_password = util::get_password(Some("NEW master password: "), &config.pinentry)?; 601 | 602 | // Use it to generate a new wrapped keypair, overwriting the previous keypair. 603 | let public_key = 604 | backend::RageLib::create_wrapped_keypair(&config.keyfile, new_password.clone())?; 605 | 606 | // Dupe the current config, update only the public key field, and write it back. 607 | let config = config::Config { 608 | public_key, 609 | ..config.clone() 610 | }; 611 | std::fs::write( 612 | Path::new(&config.config_dir).join(config::CONFIG_BASENAME), 613 | toml::to_string(&config)?, 614 | )?; 615 | 616 | // Flush the stale key from the active agent, and add the new key to the agent. 617 | // NOTE(ww): This scope is essential: we need to drop this client before we 618 | // create the new session below. Why? Because the session contains its 619 | // own agent client, and the current agent implementation only allows a 620 | // single client at a time. Clients yield their access by closing their 621 | // underlying socket, so we need to drop here to prevent a deadlock. 622 | { 623 | let client = agent::Client::new()?; 624 | client.flush_keys()?; 625 | client.add_key(&config.public_key, &config.keyfile, new_password)?; 626 | } 627 | 628 | // Create a new session from the new config and use it to re-encrypt each record. 629 | println!("Re-encrypting all records, be patient..."); 630 | let session: Session = (&config).try_into()?; 631 | for record in records { 632 | log::debug!("re-encrypting {}", record.expose_secret().label); 633 | session.add_record(record.expose_secret())?; 634 | } 635 | 636 | println!("All done."); 637 | 638 | Ok(()) 639 | } 640 | 641 | /// Implements the `kbs2 config` command. 642 | pub fn config(matches: &ArgMatches, config: &config::Config) -> Result<()> { 643 | log::debug!("config subcommand dispatch"); 644 | 645 | match matches.subcommand() { 646 | Some(("dump", matches)) => 647 | { 648 | #[allow(clippy::unwrap_used)] 649 | if *matches.get_one::("pretty").unwrap() { 650 | serde_json::to_writer_pretty(io::stdout(), &config)?; 651 | } else { 652 | serde_json::to_writer(io::stdout(), &config)?; 653 | } 654 | } 655 | Some((_, _)) => unreachable!(), 656 | None => unreachable!(), 657 | } 658 | 659 | Ok(()) 660 | } 661 | -------------------------------------------------------------------------------- /src/kbs2/config.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::env; 3 | use std::ffi::OsStr; 4 | use std::fs; 5 | use std::io::{stdin, IsTerminal}; 6 | use std::path::{Path, PathBuf}; 7 | use std::process::{Command, Stdio}; 8 | 9 | use anyhow::{anyhow, Result}; 10 | use clap::ArgMatches; 11 | use lazy_static::lazy_static; 12 | use secrecy::SecretString; 13 | use serde::{de, Deserialize, Serialize}; 14 | use xdg::BaseDirectories; 15 | 16 | use crate::kbs2::backend::{Backend, RageLib}; 17 | use crate::kbs2::generator::Generator; 18 | use crate::kbs2::util; 19 | 20 | /// The default basename for the main config file, relative to the configuration 21 | /// directory. 22 | pub static CONFIG_BASENAME: &str = "config.toml"; 23 | 24 | /// The default generate age key is placed in this file, relative to 25 | /// the configuration directory. 26 | pub static DEFAULT_KEY_BASENAME: &str = "key"; 27 | 28 | lazy_static! { 29 | // We're completely hosed if we can't find a reasonable set of base directories, 30 | // so there isn't much point in trying to avoid this `expect`. 31 | static ref XDG_DIRS: BaseDirectories = { 32 | #[allow(clippy::expect_used)] 33 | BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")) 34 | .expect("Fatal: XDG: couldn't determine reasonable base directories") 35 | }; 36 | 37 | pub static ref DEFAULT_CONFIG_DIR: PathBuf = XDG_DIRS.get_config_home(); 38 | pub static ref DEFAULT_STORE_DIR: PathBuf = XDG_DIRS.get_data_home(); 39 | } 40 | 41 | /// The main kbs2 configuration structure. 42 | /// The fields of this structure correspond directly to the fields 43 | /// loaded from the configuration file. 44 | #[derive(Clone, Debug, Deserialize, Serialize)] 45 | pub struct Config { 46 | /// The path to the directory that this configuration was loaded from. 47 | /// 48 | /// **NOTE**: This field is never loaded from the configuration file itself. 49 | #[serde(skip)] 50 | pub config_dir: String, 51 | 52 | /// The public component of the keypair. 53 | #[serde(rename = "public-key")] 54 | pub public_key: String, 55 | 56 | /// The path to a file containing the private component of the keypair, 57 | /// which may be wrapped with a passphrase. 58 | #[serde(deserialize_with = "deserialize_with_tilde")] 59 | pub keyfile: String, 60 | 61 | /// Whether or not to auto-start the kbs2 authentication agent when 62 | /// creating a session. 63 | #[serde(rename = "agent-autostart")] 64 | #[serde(default = "default_as_true")] 65 | pub agent_autostart: bool, 66 | 67 | /// Whether or not the private component of the keypair is wrapped with 68 | /// a passphrase. 69 | #[serde(default = "default_as_true")] 70 | pub wrapped: bool, 71 | 72 | /// The path to the directory where encrypted records are stored. 73 | #[serde(deserialize_with = "deserialize_with_tilde")] 74 | pub store: String, 75 | 76 | /// The pinentry binary to use for password prompts. 77 | #[serde(default)] 78 | pub pinentry: Pinentry, 79 | 80 | /// An optional command to run before each `kbs2` subcommand. 81 | #[serde(deserialize_with = "deserialize_optional_with_tilde")] 82 | #[serde(rename = "pre-hook")] 83 | #[serde(default)] 84 | pub pre_hook: Option, 85 | 86 | /// An optional command to run after each `kbs2` subcommand, on success. 87 | #[serde(deserialize_with = "deserialize_optional_with_tilde")] 88 | #[serde(rename = "post-hook")] 89 | #[serde(default)] 90 | pub post_hook: Option, 91 | 92 | /// An optional command to run after each `kbs2` subcommand, on error. 93 | #[serde(deserialize_with = "deserialize_optional_with_tilde")] 94 | #[serde(rename = "error-hook")] 95 | #[serde(default)] 96 | pub error_hook: Option, 97 | 98 | /// Whether or not any hooks are called when a hook itself invokes `kbs2`. 99 | #[serde(default)] 100 | #[serde(rename = "reentrant-hooks")] 101 | pub reentrant_hooks: bool, 102 | 103 | /// Any secret generators configured by the user. 104 | #[serde(default)] 105 | pub generators: Vec, 106 | 107 | /// Per-command configuration. 108 | #[serde(default)] 109 | pub commands: CommandConfigs, 110 | } 111 | 112 | impl Config { 113 | /// Calls a command as a hook, meaning: 114 | /// * The command is run with the `kbs2` store as its working directory 115 | /// * The command is run with `KBS2_HOOK=1` in its environment 116 | /// 117 | /// Hooks have the following behavior: 118 | /// 1. If `reentrant-hooks` is `true` *or* `KBS2_HOOK` is *not* present in the environment, 119 | /// the hook is run. 120 | /// 2. If `reentrant-hooks` is `false` (the default) *and* `KBS2_HOOK` is already present 121 | /// (indicating that we're already in a hook), nothing is run. 122 | pub fn call_hook(&self, cmd: &str, args: &[&str]) -> Result<()> { 123 | if self.reentrant_hooks || env::var("KBS2_HOOK").is_err() { 124 | let success = Command::new(cmd) 125 | .args(args) 126 | .current_dir(Path::new(&self.store)) 127 | .env("KBS2_HOOK", "1") 128 | .env("KBS2_CONFIG_DIR", &self.config_dir) 129 | .stdin(Stdio::null()) 130 | .stdout(Stdio::null()) 131 | .status() 132 | .map(|s| s.success()) 133 | .map_err(|_| anyhow!("failed to run hook: {}", cmd))?; 134 | 135 | if success { 136 | Ok(()) 137 | } else { 138 | Err(anyhow!("hook exited with an error code: {}", cmd)) 139 | } 140 | } else { 141 | util::warn("nested hook requested without reentrant-hooks; skipping"); 142 | Ok(()) 143 | } 144 | } 145 | 146 | /// Given the `name` of a configured generator, return that generator 147 | /// if it exists. 148 | pub fn generator(&self, name: &str) -> Option<&GeneratorConfig> { 149 | self.generators 150 | .iter() 151 | .find(|&generator_config| generator_config.name() == name) 152 | } 153 | 154 | /// Create a `RuntimeConfig` from this config and the given `matches`. 155 | pub fn with_matches<'a>(&'a self, matches: &'a ArgMatches) -> RuntimeConfig<'a> { 156 | RuntimeConfig { 157 | config: self, 158 | matches, 159 | } 160 | } 161 | } 162 | 163 | /// A newtype wrapper around a `String`, used to provide a sensible default for `Config.pinentry`. 164 | #[derive(Clone, Debug, Serialize, Deserialize)] 165 | pub struct Pinentry(String); 166 | 167 | impl Default for Pinentry { 168 | fn default() -> Self { 169 | Self("pinentry".into()) 170 | } 171 | } 172 | 173 | impl AsRef for Pinentry { 174 | fn as_ref(&self) -> &OsStr { 175 | self.0.as_ref() 176 | } 177 | } 178 | 179 | #[derive(Clone, Debug, Deserialize, Serialize)] 180 | pub struct GeneratorConfig { 181 | /// The name of the generator. 182 | pub name: String, 183 | 184 | /// The alphabets used by the generator. 185 | pub alphabets: Vec, 186 | 187 | /// The length of the secrets generated. 188 | pub length: usize, 189 | } 190 | 191 | impl Default for GeneratorConfig { 192 | fn default() -> Self { 193 | GeneratorConfig { 194 | name: "default".into(), 195 | alphabets: vec![ 196 | "abcdefghijklmnopqrstuvwxyz".into(), 197 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ".into(), 198 | "0123456789".into(), 199 | "(){}[]-_+=".into(), 200 | ], 201 | length: 16, 202 | } 203 | } 204 | } 205 | 206 | /// The per-command configuration settings known to `kbs2`. 207 | #[derive(Clone, Default, Debug, Deserialize, Serialize)] 208 | #[serde(default)] 209 | pub struct CommandConfigs { 210 | /// Settings for `kbs2 new`. 211 | pub new: NewConfig, 212 | 213 | /// Settings for `kbs2 pass`. 214 | pub pass: PassConfig, 215 | 216 | /// Settings for `kbs2 edit`. 217 | pub edit: EditConfig, 218 | 219 | /// Settings for `kbs2 rm`. 220 | pub rm: RmConfig, 221 | 222 | /// Settings for `kbs2 rename`. 223 | pub rename: RenameConfig, 224 | 225 | /// External command settings. 226 | pub ext: HashMap>, 227 | } 228 | 229 | /// Configuration settings for `kbs2 new`. 230 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 231 | #[serde(default)] 232 | pub struct NewConfig { 233 | #[serde(rename = "default-username")] 234 | pub default_username: Option, 235 | // TODO(ww): This deserialize_with is ugly. There's probably a better way to do this. 236 | #[serde(deserialize_with = "deserialize_optional_with_tilde")] 237 | #[serde(rename = "pre-hook")] 238 | pub pre_hook: Option, 239 | #[serde(deserialize_with = "deserialize_optional_with_tilde")] 240 | #[serde(rename = "post-hook")] 241 | pub post_hook: Option, 242 | } 243 | 244 | /// Configuration settings for `kbs2 pass`. 245 | #[derive(Clone, Debug, Deserialize, Serialize)] 246 | #[serde(default)] 247 | pub struct PassConfig { 248 | #[serde(rename = "clipboard-duration")] 249 | pub clipboard_duration: u64, 250 | #[serde(rename = "clear-after")] 251 | pub clear_after: bool, 252 | #[serde(deserialize_with = "deserialize_optional_with_tilde")] 253 | #[serde(rename = "pre-hook")] 254 | pub pre_hook: Option, 255 | #[serde(deserialize_with = "deserialize_optional_with_tilde")] 256 | #[serde(rename = "post-hook")] 257 | pub post_hook: Option, 258 | #[serde(deserialize_with = "deserialize_optional_with_tilde")] 259 | #[serde(rename = "clear-hook")] 260 | pub clear_hook: Option, 261 | } 262 | 263 | #[derive(Copy, Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] 264 | pub enum X11Clipboard { 265 | Clipboard, 266 | Primary, 267 | } 268 | 269 | impl Default for PassConfig { 270 | fn default() -> Self { 271 | PassConfig { 272 | clipboard_duration: 10, 273 | clear_after: true, 274 | pre_hook: None, 275 | post_hook: None, 276 | clear_hook: None, 277 | } 278 | } 279 | } 280 | 281 | /// Configuration settings for `kbs2 edit`. 282 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 283 | #[serde(default)] 284 | pub struct EditConfig { 285 | pub editor: Option, 286 | #[serde(deserialize_with = "deserialize_optional_with_tilde")] 287 | #[serde(rename = "post-hook")] 288 | pub post_hook: Option, 289 | } 290 | 291 | /// Configuration settings for `kbs2 rm`. 292 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 293 | #[serde(default)] 294 | pub struct RmConfig { 295 | #[serde(deserialize_with = "deserialize_optional_with_tilde")] 296 | #[serde(rename = "post-hook")] 297 | pub post_hook: Option, 298 | } 299 | 300 | /// Configuration settings for `kbs2 rename`. 301 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 302 | #[serde(default)] 303 | pub struct RenameConfig { 304 | #[serde(deserialize_with = "deserialize_optional_with_tilde")] 305 | #[serde(rename = "post-hook")] 306 | pub post_hook: Option, 307 | } 308 | 309 | /// A "view" for an active configuration, composed with some set of argument matches 310 | /// from the command line. 311 | pub struct RuntimeConfig<'a> { 312 | pub config: &'a Config, 313 | pub matches: &'a ArgMatches, 314 | } 315 | 316 | impl RuntimeConfig<'_> { 317 | pub fn generator(&self) -> Result<&GeneratorConfig> { 318 | // If the user explicitly requests a specific generator, use it. 319 | // Otherwise, use the default generator, which is always present. 320 | if let Some(generator) = self.matches.get_one::("generator") { 321 | self.config 322 | .generator(generator) 323 | .ok_or_else(|| anyhow!("no generator named {generator}")) 324 | } else { 325 | // Failure here indicates a bug, since we should always have a default. 326 | self.config 327 | .generator("default") 328 | .ok_or_else(|| anyhow!("missing default generator?")) 329 | } 330 | } 331 | 332 | pub fn terse(&self) -> bool { 333 | !stdin().is_terminal() || *self.matches.get_one::("terse").unwrap_or(&false) 334 | } 335 | } 336 | 337 | #[doc(hidden)] 338 | #[inline] 339 | fn deserialize_with_tilde<'de, D>(deserializer: D) -> std::result::Result 340 | where 341 | D: de::Deserializer<'de>, 342 | { 343 | let unexpanded: String = Deserialize::deserialize(deserializer)?; 344 | Ok(shellexpand::tilde(&unexpanded).into_owned()) 345 | } 346 | 347 | #[doc(hidden)] 348 | #[inline] 349 | fn deserialize_optional_with_tilde<'de, D>( 350 | deserializer: D, 351 | ) -> std::result::Result, D::Error> 352 | where 353 | D: de::Deserializer<'de>, 354 | { 355 | let unexpanded: Option = Deserialize::deserialize(deserializer)?; 356 | 357 | match unexpanded { 358 | Some(unexpanded) => Ok(Some(shellexpand::tilde(&unexpanded).into_owned())), 359 | None => Ok(None), 360 | } 361 | } 362 | 363 | #[doc(hidden)] 364 | #[inline] 365 | fn default_as_true() -> bool { 366 | // https://github.com/serde-rs/serde/issues/1030 367 | true 368 | } 369 | 370 | /// Given a path to a `kbs2` configuration directory, initializes a configuration 371 | /// file and keypair within it. 372 | /// 373 | /// # Arguments 374 | /// 375 | /// * `config_dir` - The configuration directory to initialize within 376 | /// * `store_dir` - The record store directory to use 377 | /// * `password` - An optional master password for wrapping the secret 378 | pub fn initialize>( 379 | config_dir: P, 380 | store_dir: P, 381 | password: Option, 382 | ) -> Result<()> { 383 | fs::create_dir_all(&config_dir)?; 384 | 385 | let keyfile = config_dir.as_ref().join(DEFAULT_KEY_BASENAME); 386 | 387 | let mut wrapped = false; 388 | let public_key = if let Some(password) = password { 389 | wrapped = true; 390 | RageLib::create_wrapped_keypair(&keyfile, password)? 391 | } else { 392 | RageLib::create_keypair(&keyfile)? 393 | }; 394 | 395 | log::debug!("public key: {}", public_key); 396 | 397 | let serialized = { 398 | let config_dir = config_dir 399 | .as_ref() 400 | .to_str() 401 | .ok_or_else(|| anyhow!("unencodable config dir"))? 402 | .into(); 403 | 404 | let store = store_dir 405 | .as_ref() 406 | .to_str() 407 | .ok_or_else(|| anyhow!("unencodable store dir"))? 408 | .into(); 409 | 410 | #[allow(clippy::redundant_field_names)] 411 | toml::to_string(&Config { 412 | // NOTE(ww): Not actually serialized; just here to make the compiler happy. 413 | config_dir: config_dir, 414 | public_key: public_key, 415 | keyfile: keyfile 416 | .to_str() 417 | .ok_or_else(|| anyhow!("unrepresentable keyfile path: {:?}", keyfile))? 418 | .into(), 419 | agent_autostart: true, 420 | wrapped: wrapped, 421 | store: store, 422 | pinentry: Default::default(), 423 | pre_hook: None, 424 | post_hook: None, 425 | error_hook: None, 426 | reentrant_hooks: false, 427 | generators: vec![Default::default()], 428 | commands: Default::default(), 429 | })? 430 | }; 431 | 432 | fs::write(config_dir.as_ref().join(CONFIG_BASENAME), serialized)?; 433 | 434 | Ok(()) 435 | } 436 | 437 | /// Given a path to a `kbs2` configuration directory, loads the configuration 438 | /// file within and returns the resulting `Config`. 439 | pub fn load>(config_dir: P) -> Result { 440 | let config_dir = config_dir.as_ref(); 441 | let config_path = config_dir.join(CONFIG_BASENAME); 442 | 443 | let contents = fs::read_to_string(config_path)?; 444 | 445 | let mut config = Config { 446 | config_dir: config_dir 447 | .to_str() 448 | .ok_or_else(|| anyhow!("unrepresentable config dir path: {:?}", config_dir))? 449 | .into(), 450 | ..toml::from_str(&contents).map_err(|e| anyhow!("config loading error: {}", e))? 451 | }; 452 | 453 | // Always put a default generator in the generator list. 454 | if config.generators.is_empty() { 455 | config.generators.push(Default::default()); 456 | } 457 | 458 | Ok(config) 459 | } 460 | 461 | #[cfg(test)] 462 | mod tests { 463 | use tempfile::tempdir; 464 | 465 | use super::*; 466 | 467 | fn dummy_config_unwrapped_key() -> Config { 468 | Config { 469 | config_dir: "/not/a/real/dir".into(), 470 | public_key: "not a real public key".into(), 471 | keyfile: "not a real private key file".into(), 472 | agent_autostart: false, 473 | wrapped: false, 474 | store: "/tmp".into(), 475 | pinentry: Default::default(), 476 | pre_hook: Some("true".into()), 477 | post_hook: Some("false".into()), 478 | error_hook: Some("true".into()), 479 | reentrant_hooks: false, 480 | generators: vec![Default::default()], 481 | commands: CommandConfigs { 482 | rm: RmConfig { 483 | post_hook: Some("this-command-does-not-exist".into()), 484 | }, 485 | ..Default::default() 486 | }, 487 | } 488 | } 489 | 490 | #[test] 491 | fn test_find_default_config_dir() { 492 | // NOTE: We can't check whether the main config dir exists since we create it if it 493 | // doesn't; instead, we just check that it isn't something weird like a regular file. 494 | assert!(!DEFAULT_CONFIG_DIR.is_file()); 495 | 496 | // The default config dir's parents aren't guaranteed to exist; we create them 497 | // if they don't. 498 | } 499 | 500 | #[test] 501 | fn test_find_default_store_dir() { 502 | // NOTE: Like above: just make sure it isn't something weird like a regular file. 503 | assert!(!DEFAULT_STORE_DIR.is_file()); 504 | 505 | // The default store dir's parents aren't guaranteed to exist; we create them 506 | // if they don't. 507 | } 508 | 509 | #[test] 510 | fn test_initialize_unwrapped() { 511 | { 512 | let config_dir = tempdir().unwrap(); 513 | let store_dir = tempdir().unwrap(); 514 | assert!(initialize(&config_dir, &store_dir, None).is_ok()); 515 | 516 | let config_dir = config_dir.path(); 517 | assert!(config_dir.exists()); 518 | assert!(config_dir.is_dir()); 519 | 520 | assert!(config_dir.join(CONFIG_BASENAME).exists()); 521 | assert!(config_dir.join(CONFIG_BASENAME).is_file()); 522 | 523 | assert!(config_dir.join(DEFAULT_KEY_BASENAME).exists()); 524 | assert!(config_dir.join(DEFAULT_KEY_BASENAME).is_file()); 525 | 526 | let config = load(config_dir).unwrap(); 527 | assert!(!config.wrapped); 528 | } 529 | } 530 | 531 | #[test] 532 | fn test_initialize_wrapped() { 533 | { 534 | let config_dir = tempdir().unwrap(); 535 | let store_dir = tempdir().unwrap(); 536 | assert!(initialize( 537 | &config_dir, 538 | &store_dir, 539 | Some(SecretString::new("badpassword".into())) 540 | ) 541 | .is_ok()); 542 | 543 | let config_dir = config_dir.path(); 544 | assert!(config_dir.exists()); 545 | assert!(config_dir.is_dir()); 546 | 547 | assert!(config_dir.join(CONFIG_BASENAME).exists()); 548 | assert!(config_dir.join(CONFIG_BASENAME).is_file()); 549 | 550 | assert!(config_dir.join(DEFAULT_KEY_BASENAME).exists()); 551 | assert!(config_dir.join(DEFAULT_KEY_BASENAME).is_file()); 552 | 553 | let config = load(config_dir).unwrap(); 554 | assert!(config.wrapped); 555 | } 556 | } 557 | 558 | #[test] 559 | fn test_load() { 560 | { 561 | let config_dir = tempdir().unwrap(); 562 | let store_dir = tempdir().unwrap(); 563 | initialize(&config_dir, &store_dir, None).unwrap(); 564 | 565 | assert!(load(&config_dir).is_ok()); 566 | } 567 | 568 | { 569 | let config_dir = tempdir().unwrap(); 570 | let store_dir = tempdir().unwrap(); 571 | initialize(&config_dir, &store_dir, None).unwrap(); 572 | 573 | let config = load(&config_dir).unwrap(); 574 | assert_eq!(config_dir.path().to_str().unwrap(), config.config_dir); 575 | assert_eq!(store_dir.path().to_str().unwrap(), config.store); 576 | } 577 | } 578 | 579 | #[test] 580 | fn test_call_hook() { 581 | let config = dummy_config_unwrapped_key(); 582 | 583 | { 584 | assert!(config 585 | .call_hook(config.pre_hook.as_ref().unwrap(), &[]) 586 | .is_ok()); 587 | } 588 | 589 | { 590 | let err = config 591 | .call_hook(config.commands.rm.post_hook.as_ref().unwrap(), &[]) 592 | .unwrap_err(); 593 | 594 | assert_eq!( 595 | err.to_string(), 596 | "failed to run hook: this-command-does-not-exist" 597 | ); 598 | } 599 | 600 | { 601 | let err = config 602 | .call_hook(config.post_hook.as_ref().unwrap(), &[]) 603 | .unwrap_err(); 604 | 605 | assert_eq!(err.to_string(), "hook exited with an error code: false"); 606 | } 607 | 608 | { 609 | assert!(config 610 | .call_hook(config.error_hook.as_ref().unwrap(), &[]) 611 | .is_ok()); 612 | } 613 | } 614 | 615 | #[test] 616 | fn test_get_generator() { 617 | let config = dummy_config_unwrapped_key(); 618 | 619 | assert!(config.generator("default").is_some()); 620 | assert!(config.generator("nonexistent-generator").is_none()); 621 | } 622 | } 623 | -------------------------------------------------------------------------------- /src/kbs2/generator.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use rand::seq::{IteratorRandom, SliceRandom}; 3 | 4 | use crate::kbs2::config; 5 | 6 | /// Represents the operations that all generators are capable of. 7 | pub trait Generator { 8 | /// Returns the name of the generator, e.g. `"default"`. 9 | fn name(&self) -> &str; 10 | 11 | /// Returns a secret produced by the generator. 12 | fn secret(&self) -> Result; 13 | } 14 | 15 | impl Generator for config::GeneratorConfig { 16 | fn name(&self) -> &str { 17 | &self.name 18 | } 19 | 20 | fn secret(&self) -> Result { 21 | // Invariants: we need at least one alphabet, and our length has to be nonzero. 22 | if self.alphabets.is_empty() { 23 | return Err(anyhow!("generator must have at least one alphabet")); 24 | } 25 | 26 | if self.length == 0 { 27 | return Err(anyhow!("generator length is invalid (must be nonzero)")); 28 | } 29 | 30 | // Our secret generation strategy: 31 | // 1. Sample each alphabet once 32 | // 2. Pad the secret out to the remaining length, sampling from all alphabets 33 | // 3. Shuffle the result 34 | 35 | let mut rng = rand::thread_rng(); 36 | let mut secret = Vec::with_capacity(self.length); 37 | for alphabet in self.alphabets.iter() { 38 | if alphabet.is_empty() { 39 | return Err(anyhow!("generator alphabet(s) must not be empty")); 40 | } 41 | 42 | // NOTE(ww): Disallow non-ASCII, to prevent gibberish indexing below. 43 | if !alphabet.is_ascii() { 44 | return Err(anyhow!( 45 | "generator alphabet(s) contain non-ascii characters" 46 | )); 47 | } 48 | 49 | // Safe unwrap: alphabet.chars() is always nonempty. 50 | #[allow(clippy::unwrap_used)] 51 | secret.push(alphabet.chars().choose(&mut rng).unwrap()); 52 | } 53 | 54 | // If step 1 generated a longer password than "length" allows, fail. 55 | if secret.len() >= self.length { 56 | return Err(anyhow!( 57 | "generator invariant failure (too many separate alphabets for length?)" 58 | )); 59 | } 60 | 61 | // Pad out with the combined alphabet. 62 | let combined_alphabet = self.alphabets.iter().flat_map(|a| a.chars()); 63 | let remainder = combined_alphabet.choose_multiple(&mut rng, self.length - secret.len()); 64 | secret.extend(remainder); 65 | 66 | // Shuffle and return. 67 | secret.shuffle(&mut rng); 68 | Ok(secret.into_iter().collect()) 69 | } 70 | } 71 | 72 | #[cfg(test)] 73 | mod tests { 74 | use super::*; 75 | 76 | fn dummy_internal_generator(alphabets: &[&str]) -> Box { 77 | Box::new(config::GeneratorConfig { 78 | name: "dummy-internal".into(), 79 | alphabets: alphabets.iter().map(|a| (*a).into()).collect(), 80 | length: 5, 81 | }) 82 | } 83 | 84 | #[test] 85 | fn test_internal_generator_invariants() { 86 | // Fails with no alphabets. 87 | { 88 | let gen = config::GeneratorConfig { 89 | name: "dummy-internal".into(), 90 | alphabets: vec![], 91 | length: 10, 92 | }; 93 | 94 | assert_eq!( 95 | gen.secret().unwrap_err().to_string(), 96 | "generator must have at least one alphabet" 97 | ); 98 | } 99 | 100 | // Fails with a length of 0. 101 | { 102 | let gen = config::GeneratorConfig { 103 | name: "dummy-internal".into(), 104 | alphabets: vec!["abcd".into()], 105 | length: 0, 106 | }; 107 | 108 | assert_eq!( 109 | gen.secret().unwrap_err().to_string(), 110 | "generator length is invalid (must be nonzero)" 111 | ); 112 | } 113 | 114 | // Fails if an alphabet is non-ASCII. 115 | { 116 | let gen = dummy_internal_generator(&["ⓓⓔⓕⓘⓝⓘⓣⓔⓛⓨ ⓝⓞⓣ ⓐⓢⓒⓘⓘ"]); 117 | let err = gen.secret().unwrap_err(); 118 | assert_eq!( 119 | err.to_string(), 120 | "generator alphabet(s) contain non-ascii characters" 121 | ); 122 | } 123 | 124 | // Fails if any individual alphabet is empty. 125 | { 126 | let gen = dummy_internal_generator(&[""]); 127 | let err = gen.secret().unwrap_err(); 128 | assert_eq!(err.to_string(), "generator alphabet(s) must not be empty"); 129 | } 130 | 131 | // Fails if there are more alphabets than available length. 132 | { 133 | let gen = config::GeneratorConfig { 134 | name: "dummy-internal".into(), 135 | alphabets: vec!["abc", "def", "ghi"] 136 | .into_iter() 137 | .map(Into::into) 138 | .collect(), 139 | length: 2, 140 | }; 141 | 142 | assert_eq!( 143 | gen.secret().unwrap_err().to_string(), 144 | "generator invariant failure (too many separate alphabets for length?)" 145 | ); 146 | } 147 | 148 | // Succeeds and upholds length and inclusion invariants. 149 | { 150 | let alphabets = ["abcd", "1234", "!@#$"]; 151 | 152 | let gen = config::GeneratorConfig { 153 | name: "dummy-internal".into(), 154 | alphabets: alphabets.into_iter().map(Into::into).collect(), 155 | length: 10, 156 | }; 157 | 158 | for secret in (0..100).map(|_| gen.secret()) { 159 | assert!(secret.is_ok()); 160 | 161 | let secret = secret.unwrap(); 162 | assert_eq!(secret.len(), 10); 163 | assert!(alphabets 164 | .iter() 165 | .all(|a| a.chars().any(|c| secret.contains(c)))); 166 | } 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/kbs2/input.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Read}; 2 | 3 | use anyhow::{anyhow, Result}; 4 | use inquire::{Password as Pass, Text}; 5 | 6 | use super::record::{EnvironmentFields, LoginFields, RecordBody, UnstructuredFields}; 7 | use crate::kbs2::config::RuntimeConfig; 8 | use crate::kbs2::generator::Generator; 9 | 10 | /// The input separator used when input is gathered in "terse" mode. 11 | pub static TERSE_IFS: &str = "\x01"; 12 | 13 | pub trait Input { 14 | const FIELD_COUNT: usize; 15 | 16 | fn from_prompt(config: &RuntimeConfig) -> Result; 17 | fn from_terse(config: &RuntimeConfig) -> Result; 18 | 19 | fn take_terse_fields() -> Result> { 20 | let mut input = String::new(); 21 | io::stdin().read_to_string(&mut input)?; 22 | 23 | if input.ends_with('\n') { 24 | input.pop(); 25 | } 26 | 27 | let fields = input 28 | .splitn(Self::FIELD_COUNT, TERSE_IFS) 29 | .map(Into::into) 30 | .collect::>(); 31 | 32 | if fields.len() != Self::FIELD_COUNT { 33 | return Err(anyhow!( 34 | "field count mismatch: expected {}, got {}", 35 | Self::FIELD_COUNT, 36 | fields.len() 37 | )); 38 | } 39 | 40 | Ok(fields) 41 | } 42 | 43 | fn input(config: &RuntimeConfig) -> Result { 44 | if config.terse() { 45 | Self::from_terse(config) 46 | } else { 47 | Self::from_prompt(config) 48 | } 49 | } 50 | } 51 | 52 | impl Input for LoginFields { 53 | const FIELD_COUNT: usize = 2; 54 | 55 | fn from_prompt(config: &RuntimeConfig) -> Result { 56 | let username = if let Some(default_username) = &config.config.commands.new.default_username 57 | { 58 | Text::new("Username?") 59 | .with_default(default_username) 60 | .prompt()? 61 | } else { 62 | Text::new("Username?").prompt()? 63 | }; 64 | 65 | let mut password = Pass::new("Password?") 66 | .with_help_message("Press [enter] to auto-generate") 67 | .without_confirmation() 68 | .prompt()?; 69 | 70 | if password.is_empty() { 71 | password = config.generator()?.secret()?; 72 | } 73 | 74 | Ok(RecordBody::Login(LoginFields { username, password })) 75 | } 76 | 77 | fn from_terse(config: &RuntimeConfig) -> Result { 78 | // NOTE: Backwards order here because we're popping from the vector. 79 | let (mut password, username) = { 80 | let mut fields = Self::take_terse_fields()?; 81 | 82 | // Unwrap safety: take_terse_fields checks FIELD_COUNT to ensure sufficient elements. 83 | #[allow(clippy::unwrap_used)] 84 | (fields.pop().unwrap(), fields.pop().unwrap()) 85 | }; 86 | 87 | if password.is_empty() { 88 | password = config.generator()?.secret()?; 89 | } 90 | 91 | Ok(RecordBody::Login(LoginFields { username, password })) 92 | } 93 | } 94 | 95 | impl Input for EnvironmentFields { 96 | const FIELD_COUNT: usize = 2; 97 | 98 | fn from_prompt(config: &RuntimeConfig) -> Result { 99 | let variable = Text::new("Variable?").prompt()?; 100 | let mut value = Pass::new("Value?") 101 | .with_help_message("Press [enter] to auto-generate") 102 | .prompt()?; 103 | 104 | if value.is_empty() { 105 | value = config.generator()?.secret()?; 106 | } 107 | 108 | Ok(RecordBody::Environment(EnvironmentFields { 109 | variable, 110 | value, 111 | })) 112 | } 113 | 114 | fn from_terse(config: &RuntimeConfig) -> Result { 115 | // NOTE: Backwards order here because we're popping from the vector. 116 | let (mut value, variable) = { 117 | let mut fields = Self::take_terse_fields()?; 118 | 119 | // Unwrap safety: take_terse_fields checks FIELD_COUNT to ensure sufficient elements. 120 | #[allow(clippy::unwrap_used)] 121 | (fields.pop().unwrap(), fields.pop().unwrap()) 122 | }; 123 | 124 | if value.is_empty() { 125 | value = config.generator()?.secret()?; 126 | } 127 | 128 | Ok(RecordBody::Environment(EnvironmentFields { 129 | variable, 130 | value, 131 | })) 132 | } 133 | } 134 | 135 | impl Input for UnstructuredFields { 136 | const FIELD_COUNT: usize = 1; 137 | 138 | fn from_prompt(_config: &RuntimeConfig) -> Result { 139 | let contents = Text::new("Contents?").prompt()?; 140 | 141 | Ok(RecordBody::Unstructured(UnstructuredFields { contents })) 142 | } 143 | 144 | fn from_terse(_config: &RuntimeConfig) -> Result { 145 | // Unwrap safety: take_terse_fields checks FIELD_COUNT to ensure sufficient elements. 146 | #[allow(clippy::unwrap_used)] 147 | let contents = Self::take_terse_fields()?.pop().unwrap(); 148 | 149 | Ok(RecordBody::Unstructured(UnstructuredFields { contents })) 150 | } 151 | } 152 | 153 | // /// Given an array of field names and a potential generator, grabs the values for 154 | // /// those fields in a terse manner (each separated by `TERSE_IFS`). 155 | // /// 156 | // /// Fields that are marked as sensitive are subsequently overwritten by the 157 | // /// generator, if one is provided. 158 | // fn terse_fields(names: &[FieldKind], generator: Option<&dyn Generator>) -> Result> { 159 | // let mut input = String::new(); 160 | // io::stdin().read_to_string(&mut input)?; 161 | 162 | // if input.ends_with('\n') { 163 | // input.pop(); 164 | // } 165 | 166 | // // NOTE(ww): Handling generated inputs in terse mode is a bit of a mess. 167 | // // First, we collect all inputs, expecting blank slots where we'll fill 168 | // // in the generated values. 169 | // let mut fields = input 170 | // .split(TERSE_IFS) 171 | // .map(|s| s.to_string()) 172 | // .collect::>(); 173 | // if fields.len() != names.len() { 174 | // return Err(anyhow!( 175 | // "field count mismatch: expected {}, found {}", 176 | // names.len(), 177 | // fields.len() 178 | // )); 179 | // } 180 | 181 | // // Then, if we have a generator configured, we iterate over the 182 | // // fields and insert them as appropriate. 183 | // if let Some(generator) = generator { 184 | // for (i, name) in names.iter().enumerate() { 185 | // if let Sensitive(_) = name { 186 | // let field = fields.get_mut(i).unwrap(); 187 | // field.clear(); 188 | // field.push_str(&generator.secret()?); 189 | // } 190 | // } 191 | // } 192 | 193 | // Ok(fields) 194 | // } 195 | 196 | // /// Given an array of field names and a potential generator, grabs the values for those 197 | // /// fields by prompting the user for each. 198 | // /// 199 | // /// If a field is marked as sensitive **and** a generator is provided, the generator 200 | // /// is used to provide that field and the user is **not** prompted. 201 | // fn interactive_fields( 202 | // names: &[FieldKind], 203 | // config: &Config, 204 | // generator: Option<&dyn Generator>, 205 | // ) -> Result> { 206 | // let mut fields = vec![]; 207 | 208 | // for name in names { 209 | // let field = match name { 210 | // Sensitive(name) => { 211 | // if let Some(generator) = generator { 212 | // generator.secret()? 213 | // } else { 214 | // let field = Password::new() 215 | // .with_prompt(*name) 216 | // .allow_empty_password(config.commands.new.generate_on_empty) 217 | // .interact()?; 218 | 219 | // if field.is_empty() && config.commands.new.generate_on_empty { 220 | // log::debug!("generate-on-empty with an empty field, generating a secret"); 221 | 222 | // let generator = config.get_generator("default").ok_or_else(|| { 223 | // anyhow!("generate-on-empty configured but no default generator") 224 | // })?; 225 | 226 | // generator.secret()? 227 | // } else { 228 | // field 229 | // } 230 | // } 231 | // } 232 | // Insensitive(name) => Input::::new().with_prompt(*name).interact()?, 233 | // }; 234 | 235 | // fields.push(field); 236 | // } 237 | 238 | // Ok(fields) 239 | // } 240 | 241 | // /// Grabs the values for a set of field names from user input. 242 | // /// 243 | // /// # Arguments 244 | // /// 245 | // /// * `names` - the set of field names to grab 246 | // /// * `terse` - whether or not to get fields tersely, i.e. by splitting on 247 | // /// `TERSE_IFS` instead of prompting for each 248 | // /// * `config` - the active `Config` 249 | // /// * `generator` - the generator, if any, to use for sensitive fields 250 | // pub fn fields( 251 | // names: &[FieldKind], 252 | // terse: bool, 253 | // config: &Config, 254 | // generator: Option<&dyn Generator>, 255 | // ) -> Result> { 256 | // if terse { 257 | // terse_fields(names, generator) 258 | // } else { 259 | // interactive_fields(names, config, generator) 260 | // } 261 | // } 262 | -------------------------------------------------------------------------------- /src/kbs2/mod.rs: -------------------------------------------------------------------------------- 1 | /// Structures and routines for the `kbs2` authentication agent. 2 | pub mod agent; 3 | 4 | /// Structures and routines for interacting with age backends. 5 | pub mod backend; 6 | 7 | /// Routines for the various `kbs2` subcommands. 8 | pub mod command; 9 | 10 | /// Structures and routines for `kbs2`'s configuration. 11 | pub mod config; 12 | 13 | /// Structures and routines for secret generators. 14 | pub mod generator; 15 | 16 | /// Routines for handling user input. 17 | pub mod input; 18 | 19 | /// Structures and routines for creating and managing individual `kbs2` records. 20 | pub mod record; 21 | 22 | /// Structures and routines for creating and managing an active `kbs2` session. 23 | pub mod session; 24 | 25 | /// Reusable utility code for `kbs2`. 26 | pub mod util; 27 | -------------------------------------------------------------------------------- /src/kbs2/record.rs: -------------------------------------------------------------------------------- 1 | use secrecy::Zeroize; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::kbs2::util; 5 | 6 | // TODO(ww): Figure out how to generate this from the RecordBody enum below. 7 | /// The stringified names of record kinds known to `kbs2`. 8 | pub static RECORD_KINDS: &[&str] = &["login", "environment", "unstructured"]; 9 | 10 | /// Represents the envelope of a `kbs2` record. 11 | #[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] 12 | pub struct Record { 13 | /// When the record was created, as seconds since the Unix epoch. 14 | pub timestamp: u64, 15 | 16 | /// The identifying label of the record. 17 | pub label: String, 18 | 19 | /// The type contents of the record. 20 | pub body: RecordBody, 21 | } 22 | 23 | impl Zeroize for Record { 24 | fn zeroize(&mut self) { 25 | self.timestamp.zeroize(); 26 | self.label.zeroize(); 27 | self.body.zeroize(); 28 | } 29 | } 30 | 31 | /// Represents the core contents of a `kbs2` record. 32 | #[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] 33 | #[serde(tag = "kind", content = "fields")] 34 | pub enum RecordBody { 35 | Login(LoginFields), 36 | Environment(EnvironmentFields), 37 | Unstructured(UnstructuredFields), 38 | } 39 | 40 | impl Zeroize for RecordBody { 41 | fn zeroize(&mut self) { 42 | match self { 43 | RecordBody::Login(l) => l.zeroize(), 44 | RecordBody::Environment(e) => e.zeroize(), 45 | RecordBody::Unstructured(u) => u.zeroize(), 46 | }; 47 | } 48 | } 49 | 50 | impl std::fmt::Display for RecordBody { 51 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 52 | match *self { 53 | RecordBody::Login(_) => write!(f, "login"), 54 | RecordBody::Environment(_) => write!(f, "environment"), 55 | RecordBody::Unstructured(_) => write!(f, "unstructured"), 56 | } 57 | } 58 | } 59 | 60 | /// Represents the fields of a login record. 61 | #[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] 62 | pub struct LoginFields { 63 | /// The username associated with the login. 64 | pub username: String, 65 | 66 | /// The password associated with the login. 67 | pub password: String, 68 | } 69 | 70 | impl Zeroize for LoginFields { 71 | fn zeroize(&mut self) { 72 | self.username.zeroize(); 73 | self.password.zeroize(); 74 | } 75 | } 76 | 77 | /// Represents the fields of an environment record. 78 | #[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] 79 | pub struct EnvironmentFields { 80 | /// The variable associated with the environment. 81 | pub variable: String, 82 | 83 | /// The value associated with the environment. 84 | pub value: String, 85 | } 86 | 87 | impl Zeroize for EnvironmentFields { 88 | fn zeroize(&mut self) { 89 | self.variable.zeroize(); 90 | self.value.zeroize(); 91 | } 92 | } 93 | 94 | /// Represents the fields of an unstructured record. 95 | #[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] 96 | pub struct UnstructuredFields { 97 | /// The contents associated with the record. 98 | pub contents: String, 99 | } 100 | 101 | impl Zeroize for UnstructuredFields { 102 | fn zeroize(&mut self) { 103 | self.contents.zeroize(); 104 | } 105 | } 106 | 107 | impl Record { 108 | pub fn new(label: &str, body: RecordBody) -> Record { 109 | Record { 110 | timestamp: util::current_timestamp(), 111 | label: label.into(), 112 | body, 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/kbs2/session.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | use std::fs; 3 | use std::io; 4 | use std::path::Path; 5 | 6 | use anyhow::{anyhow, Result}; 7 | 8 | use crate::kbs2::agent::Agent; 9 | use crate::kbs2::backend::{Backend, RageLib}; 10 | use crate::kbs2::config; 11 | use crate::kbs2::record; 12 | 13 | /// Encapsulates the context needed by `kbs2` to interact with records. 14 | pub struct Session<'a> { 15 | /// The `RageLib` backend used to encrypt and decrypt records. 16 | pub backend: RageLib, 17 | 18 | /// The configuration that `kbs2` was invoked with. 19 | pub config: &'a config::Config, 20 | } 21 | 22 | impl<'a> Session<'a> { 23 | /// Creates a new session, given a `Config`. 24 | fn new(config: &'a config::Config) -> Result> { 25 | // NOTE(ww): I don't like that we do this here, but I'm not sure where else to put it. 26 | if config.wrapped && config.agent_autostart { 27 | Agent::spawn()?; 28 | } 29 | 30 | fs::create_dir_all(&config.store)?; 31 | 32 | #[allow(clippy::redundant_field_names)] 33 | Ok(Session { 34 | backend: RageLib::new(config)?, 35 | config: config, 36 | }) 37 | } 38 | 39 | /// Returns the label of every record available in the store. 40 | pub fn record_labels(&self) -> Result> { 41 | let store = Path::new(&self.config.store); 42 | 43 | if !store.is_dir() { 44 | return Err(anyhow!("secret store is not a directory")); 45 | } 46 | 47 | let mut labels = vec![]; 48 | for entry in fs::read_dir(store)? { 49 | let path = entry?.path(); 50 | if !path.is_file() { 51 | log::debug!("skipping non-file in store: {:?}", path); 52 | continue; 53 | } 54 | 55 | // NOTE(ww): This unwrap is safe, since file_name always returns Some 56 | // for non-directories. 57 | #[allow(clippy::expect_used)] 58 | let label = path 59 | .file_name() 60 | .expect("impossible: is_file=true for path but file_name=None"); 61 | 62 | // NOTE(ww): This one isn't safe, but we don't care. Non-UTF-8 labels aren't supported. 63 | labels.push( 64 | label 65 | .to_str() 66 | .ok_or_else(|| anyhow!("unrepresentable record label: {:?}", label))? 67 | .into(), 68 | ); 69 | } 70 | 71 | Ok(labels) 72 | } 73 | 74 | /// Returns whether or not the store contains a given record. 75 | pub fn has_record(&self, label: &str) -> bool { 76 | let record_path = Path::new(&self.config.store).join(label); 77 | 78 | record_path.is_file() 79 | } 80 | 81 | /// Retrieves a record from the store by its label. 82 | pub fn get_record(&self, label: &str) -> Result { 83 | if !self.has_record(label) { 84 | return Err(anyhow!("no such record: {}", label)); 85 | } 86 | 87 | let record_path = Path::new(&self.config.store).join(label); 88 | let record_contents = fs::read_to_string(record_path).map_err(|e| match e.kind() { 89 | io::ErrorKind::NotFound => anyhow!("no such record: {}", label), 90 | _ => e.into(), 91 | })?; 92 | 93 | match self.backend.decrypt(&record_contents) { 94 | Ok(record) => Ok(record), 95 | Err(e) => Err(e), 96 | } 97 | } 98 | 99 | /// Adds the given record to the store. 100 | pub fn add_record(&self, record: &record::Record) -> anyhow::Result<()> { 101 | let record_path = Path::new(&self.config.store).join(&record.label); 102 | 103 | let record_contents = self.backend.encrypt(record)?; 104 | std::fs::write(record_path, record_contents)?; 105 | 106 | Ok(()) 107 | } 108 | 109 | /// Deletes a record from the store by label. 110 | pub fn delete_record(&self, label: &str) -> Result<()> { 111 | let record_path = Path::new(&self.config.store).join(label); 112 | 113 | std::fs::remove_file(record_path).map_err(|e| match e.kind() { 114 | io::ErrorKind::NotFound => anyhow!("no such record: {}", label), 115 | _ => e.into(), 116 | }) 117 | } 118 | 119 | /// Renames a record. 120 | pub fn rename_record(&self, old_label: &str, new_label: &str) -> Result<()> { 121 | let mut record = self.get_record(old_label)?; 122 | 123 | record.label = new_label.into(); 124 | self.add_record(&record)?; 125 | self.delete_record(old_label)?; 126 | 127 | Ok(()) 128 | } 129 | } 130 | 131 | impl<'a> TryFrom<&'a config::Config> for Session<'a> { 132 | type Error = anyhow::Error; 133 | 134 | fn try_from(config: &'a config::Config) -> Result { 135 | Self::new(config) 136 | } 137 | } 138 | 139 | #[cfg(test)] 140 | mod tests { 141 | use tempfile::{tempdir, TempDir}; 142 | 143 | use super::*; 144 | use crate::kbs2::record::{LoginFields, Record, RecordBody}; 145 | 146 | fn dummy_login(label: &str, username: &str, password: &str) -> Record { 147 | Record::new( 148 | label, 149 | RecordBody::Login(LoginFields { 150 | username: username.into(), 151 | password: password.into(), 152 | }), 153 | ) 154 | } 155 | 156 | // NOTE: We pass store in here instead of creating it for lifetime reasons: 157 | // the temp dir is unlinked when its TempDir object is destructed, so we need 158 | // to keep it alive long enough for each unit test. 159 | fn dummy_config(store: &TempDir) -> config::Config { 160 | config::Config { 161 | config_dir: "/not/a/real/dir".into(), 162 | // NOTE: We create the backend above manually, so the public_key and keyfile 163 | // here are dummy values that shouldn't need to be interacted with. 164 | public_key: "not a real public key".into(), 165 | keyfile: "not a real private key file".into(), 166 | agent_autostart: false, 167 | wrapped: false, 168 | store: store.path().to_str().unwrap().into(), 169 | pinentry: Default::default(), 170 | pre_hook: None, 171 | post_hook: None, 172 | error_hook: None, 173 | reentrant_hooks: false, 174 | generators: vec![Default::default()], 175 | commands: Default::default(), 176 | } 177 | } 178 | 179 | fn dummy_session(config: &config::Config) -> Session { 180 | let backend = { 181 | let key = age::x25519::Identity::generate(); 182 | 183 | RageLib { 184 | pubkey: key.to_public(), 185 | identities: vec![key], 186 | } 187 | }; 188 | 189 | Session { backend, config } 190 | } 191 | 192 | // TODO: Figure out how to test Session::new. Doing so will require an interface for 193 | // creating + initializing a config that doesn't unconditionally put the store directory 194 | // within the user's data directory. 195 | 196 | #[test] 197 | fn test_record_labels() { 198 | { 199 | let store = tempdir().unwrap(); 200 | let config = dummy_config(&store); 201 | let session = dummy_session(&config); 202 | 203 | assert_eq!(session.record_labels().unwrap(), Vec::::new()); 204 | } 205 | 206 | { 207 | let store = tempdir().unwrap(); 208 | let config = dummy_config(&store); 209 | let session = dummy_session(&config); 210 | let record = dummy_login("foo", "bar", "baz"); 211 | 212 | session.add_record(&record).unwrap(); 213 | assert_eq!(session.record_labels().unwrap(), vec!["foo"]); 214 | } 215 | } 216 | 217 | #[test] 218 | fn test_has_record() { 219 | { 220 | let store = tempdir().unwrap(); 221 | let config = dummy_config(&store); 222 | let session = dummy_session(&config); 223 | let record = dummy_login("foo", "bar", "baz"); 224 | 225 | session.add_record(&record).unwrap(); 226 | assert!(session.has_record("foo")); 227 | } 228 | 229 | { 230 | let store = tempdir().unwrap(); 231 | let config = dummy_config(&store); 232 | let session = dummy_session(&config); 233 | 234 | assert!(!session.has_record("does-not-exist")); 235 | } 236 | } 237 | 238 | #[test] 239 | fn test_get_record() { 240 | { 241 | let store = tempdir().unwrap(); 242 | let config = dummy_config(&store); 243 | let session = dummy_session(&config); 244 | let record = dummy_login("foo", "bar", "baz"); 245 | 246 | session.add_record(&record).unwrap(); 247 | 248 | let retrieved_record = session.get_record("foo").unwrap(); 249 | 250 | assert_eq!(record, retrieved_record); 251 | } 252 | 253 | { 254 | let store = tempdir().unwrap(); 255 | let config = dummy_config(&store); 256 | let session = dummy_session(&config); 257 | 258 | let err = session.get_record("foo").unwrap_err(); 259 | assert_eq!(err.to_string(), "no such record: foo"); 260 | } 261 | } 262 | 263 | #[test] 264 | fn test_add_record() { 265 | { 266 | let store = tempdir().unwrap(); 267 | let config = dummy_config(&store); 268 | let session = dummy_session(&config); 269 | 270 | let record1 = dummy_login("foo", "bar", "baz"); 271 | session.add_record(&record1).unwrap(); 272 | 273 | let record2 = dummy_login("a", "b", "c"); 274 | session.add_record(&record2).unwrap(); 275 | 276 | // NOTE: record_labels() returns labels in a platform dependent order, 277 | // which is why we don't compared against a fixed-order vec here or below. 278 | assert_eq!(session.record_labels().unwrap().len(), 2); 279 | assert!(session.record_labels().unwrap().contains(&"foo".into())); 280 | assert!(session.record_labels().unwrap().contains(&"a".into())); 281 | 282 | // Overwrite foo; still only two records. 283 | let record3 = dummy_login("foo", "quux", "zap"); 284 | session.add_record(&record3).unwrap(); 285 | 286 | assert_eq!(session.record_labels().unwrap().len(), 2); 287 | assert!(session.record_labels().unwrap().contains(&"foo".into())); 288 | assert!(session.record_labels().unwrap().contains(&"a".into())); 289 | } 290 | } 291 | 292 | #[test] 293 | fn test_delete_record() { 294 | { 295 | let store = tempdir().unwrap(); 296 | let config = dummy_config(&store); 297 | let session = dummy_session(&config); 298 | let record = dummy_login("foo", "bar", "baz"); 299 | 300 | session.add_record(&record).unwrap(); 301 | 302 | assert!(session.delete_record("foo").is_ok()); 303 | assert!(!session.has_record("foo")); 304 | assert_eq!(session.record_labels().unwrap(), Vec::::new()); 305 | } 306 | 307 | { 308 | let store = tempdir().unwrap(); 309 | let config = dummy_config(&store); 310 | let session = dummy_session(&config); 311 | 312 | let record1 = dummy_login("foo", "bar", "baz"); 313 | session.add_record(&record1).unwrap(); 314 | 315 | let record2 = dummy_login("a", "b", "c"); 316 | session.add_record(&record2).unwrap(); 317 | 318 | assert!(session.delete_record("foo").is_ok()); 319 | assert_eq!(session.record_labels().unwrap(), vec!["a"]); 320 | } 321 | 322 | { 323 | let store = tempdir().unwrap(); 324 | let config = dummy_config(&store); 325 | let session = dummy_session(&config); 326 | 327 | let err = session.delete_record("does-not-exist").unwrap_err(); 328 | assert_eq!(err.to_string(), "no such record: does-not-exist"); 329 | } 330 | } 331 | 332 | #[test] 333 | fn test_rename_record() { 334 | { 335 | let store = tempdir().unwrap(); 336 | let config = dummy_config(&store); 337 | let session = dummy_session(&config); 338 | let record = dummy_login("foo", "bar", "baz"); 339 | 340 | session.add_record(&record).unwrap(); 341 | assert!(session.has_record("foo")); 342 | 343 | session.rename_record("foo", "bar").unwrap(); 344 | assert!(!session.has_record("foo")); 345 | assert!(session.has_record("bar")); 346 | } 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /src/kbs2/util.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsStr; 2 | use std::fs::File; 3 | use std::io::Read; 4 | use std::path::Path; 5 | use std::time::{SystemTime, UNIX_EPOCH}; 6 | 7 | use anyhow::{anyhow, Result}; 8 | use pinentry::PassphraseInput; 9 | use secrecy::SecretString; 10 | 11 | /// Given an input string formatted according to shell quoting rules, 12 | /// split it into its command and argument parts and return each. 13 | pub fn parse_and_split_args(argv: &str) -> Result<(String, Vec)> { 14 | let args = match shell_words::split(argv) { 15 | Ok(args) => args, 16 | Err(_) => return Err(anyhow!("failed to split command-line arguments: {}", argv)), 17 | }; 18 | 19 | let (command, args) = args 20 | .split_first() 21 | .map(|t| (t.0.to_owned(), t.1.to_owned())) 22 | .ok_or_else(|| anyhow!("missing one or more arguments in command"))?; 23 | 24 | Ok((command, args)) 25 | } 26 | 27 | /// Securely retrieve a password from the user. 28 | /// 29 | /// NOTE: This function currently uses pinentry internally, which 30 | /// will delegate to the appropriate pinentry binary on the user's 31 | /// system. 32 | pub fn get_password>( 33 | prompt: Option<&'static str>, 34 | pinentry: S, 35 | ) -> Result { 36 | let prompt = prompt.unwrap_or("Password: "); 37 | if let Some(mut input) = PassphraseInput::with_binary(pinentry) { 38 | input 39 | .with_description("Enter your master kbs2 password") 40 | .with_prompt(prompt) 41 | .required("A non-empty password is required") 42 | .interact() 43 | .map_err(|e| anyhow!("pinentry failed: {}", e.to_string())) 44 | } else { 45 | log::debug!("no pinentry binary, falling back on rpassword"); 46 | 47 | rpassword::prompt_password(prompt) 48 | .map(SecretString::new) 49 | .map_err(|e| anyhow!("password prompt failed: {}", e.to_string())) 50 | } 51 | } 52 | 53 | /// Return the current timestamp as seconds since the UNIX epoch. 54 | pub fn current_timestamp() -> u64 { 55 | // NOTE(ww): This unwrap should be safe, since every time should be 56 | // greater than or equal to the epoch. 57 | #[allow(clippy::expect_used)] 58 | SystemTime::now() 59 | .duration_since(UNIX_EPOCH) 60 | .expect("impossible: system time is before the UNIX epoch") 61 | .as_secs() 62 | } 63 | 64 | /// Print the given message on `stderr` with a warning prefix. 65 | pub fn warn(msg: &str) { 66 | eprintln!("Warn: {msg}"); 67 | } 68 | 69 | /// Read the entire given file into a `Vec`, or fail if its on-disk size exceeds 70 | /// some limit. 71 | pub fn read_guarded>(path: P, limit: u64) -> Result> { 72 | let mut file = File::open(&path)?; 73 | let meta = file.metadata()?; 74 | if meta.len() > limit { 75 | return Err(anyhow!("requested file is suspiciously large, refusing")); 76 | } 77 | 78 | let mut buf = Vec::with_capacity(meta.len() as usize); 79 | file.read_to_end(&mut buf)?; 80 | 81 | Ok(buf) 82 | } 83 | 84 | #[cfg(test)] 85 | mod tests { 86 | use std::io::Write; 87 | 88 | use tempfile::NamedTempFile; 89 | 90 | use super::*; 91 | 92 | #[test] 93 | fn test_parse_and_split_args() { 94 | { 95 | let (cmd, args) = parse_and_split_args("just-a-command").unwrap(); 96 | assert_eq!(cmd, "just-a-command"); 97 | assert_eq!(args, Vec::::new()); 98 | } 99 | 100 | { 101 | let (cmd, args) = 102 | parse_and_split_args("foo -a -ab --c -d=e --f=g bar baz quux").unwrap(); 103 | assert_eq!(cmd, "foo"); 104 | assert_eq!( 105 | args, 106 | vec!["-a", "-ab", "--c", "-d=e", "--f=g", "bar", "baz", "quux"] 107 | ); 108 | } 109 | 110 | { 111 | let (cmd, args) = parse_and_split_args("foo 'one arg' \"another arg\" ''").unwrap(); 112 | 113 | assert_eq!(cmd, "foo"); 114 | assert_eq!(args, vec!["one arg", "another arg", ""]); 115 | } 116 | 117 | { 118 | let err = parse_and_split_args("some 'bad {syntax").unwrap_err(); 119 | assert_eq!( 120 | err.to_string(), 121 | "failed to split command-line arguments: some 'bad {syntax" 122 | ); 123 | } 124 | 125 | { 126 | let err = parse_and_split_args("").unwrap_err(); 127 | assert_eq!(err.to_string(), "missing one or more arguments in command"); 128 | } 129 | } 130 | 131 | // TODO: Figure out a good way to test util::get_password. 132 | 133 | #[test] 134 | fn test_current_timestamp() { 135 | { 136 | let ts = current_timestamp(); 137 | assert!(ts != 0); 138 | } 139 | 140 | { 141 | let ts1 = current_timestamp(); 142 | let ts2 = current_timestamp(); 143 | 144 | assert!(ts2 >= ts1); 145 | } 146 | } 147 | 148 | // TODO: Figure out a good way to test util::warn. 149 | 150 | #[test] 151 | fn test_read_guarded() { 152 | { 153 | let mut small = NamedTempFile::new().unwrap(); 154 | small.write_all(b"test").unwrap(); 155 | small.flush().unwrap(); 156 | 157 | let contents = read_guarded(small.path(), 1024); 158 | assert!(contents.is_ok()); 159 | assert_eq!(contents.unwrap().as_slice(), b"test"); 160 | } 161 | 162 | { 163 | let mut toobig = NamedTempFile::new().unwrap(); 164 | toobig.write_all(b"slightlytoobig").unwrap(); 165 | toobig.flush().unwrap(); 166 | 167 | assert!(read_guarded(toobig.path(), 10).is_err()); 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! The entrypoint for the `kbs2` CLI. 2 | 3 | #![deny(rustdoc::broken_intra_doc_links)] 4 | #![deny(missing_docs)] 5 | #![deny(clippy::unwrap_used)] 6 | #![deny(clippy::expect_used)] 7 | #![deny(clippy::panic)] 8 | 9 | use std::ffi::{OsStr, OsString}; 10 | use std::process; 11 | use std::{io, path::PathBuf}; 12 | 13 | use anyhow::{anyhow, Context, Result}; 14 | use clap::builder::{EnumValueParser, PossibleValuesParser, ValueParser}; 15 | use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint}; 16 | use clap_complete::{generate, Shell}; 17 | 18 | mod kbs2; 19 | 20 | fn app() -> Command { 21 | // TODO(ww): Put this in a separate file, or switch to YAML. 22 | // The latter probably won't work with env!, though. 23 | Command::new(env!("CARGO_PKG_NAME")) 24 | .allow_external_subcommands(true) 25 | .version(env!("KBS2_BUILD_VERSION")) 26 | .about(env!("CARGO_PKG_DESCRIPTION")) 27 | .arg( 28 | Arg::new("config-dir") 29 | .help("use the specified config directory") 30 | .short('c') 31 | .long("config-dir") 32 | .value_name("DIR") 33 | .value_parser(ValueParser::path_buf()) 34 | .env("KBS2_CONFIG_DIR") 35 | .default_value(>::as_ref( 36 | &kbs2::config::DEFAULT_CONFIG_DIR, 37 | )) 38 | .value_hint(ValueHint::DirPath), 39 | ) 40 | .arg( 41 | Arg::new("completions") 42 | .help("emit shell tab completions") 43 | .long("completions") 44 | .value_name("SHELL") 45 | .value_parser(EnumValueParser::::new()), 46 | ) 47 | .subcommand( 48 | Command::new("agent") 49 | .about("run the kbs2 authentication agent") 50 | .arg( 51 | Arg::new("foreground") 52 | .help("run the agent in the foreground") 53 | .short('F') 54 | .long("foreground") 55 | .action(ArgAction::SetTrue), 56 | ) 57 | .subcommand( 58 | Command::new("flush") 59 | .about("remove all unwrapped keys from the running agent") 60 | .arg( 61 | Arg::new("quit") 62 | .help("quit the agent after flushing") 63 | .short('q') 64 | .long("quit") 65 | .action(ArgAction::SetTrue), 66 | ), 67 | ) 68 | .subcommand( 69 | Command::new("query") 70 | .about("ask the current agent whether it has the current config's key"), 71 | ) 72 | .subcommand( 73 | Command::new("unwrap") 74 | .about("unwrap the current config's key in the running agent"), 75 | ), 76 | ) 77 | .subcommand( 78 | Command::new("init") 79 | .about("initialize kbs2 with a new config and keypair") 80 | .arg( 81 | Arg::new("force") 82 | .help("overwrite the config and keyfile, if already present") 83 | .short('f') 84 | .long("force") 85 | .action(ArgAction::SetTrue), 86 | ) 87 | .arg( 88 | Arg::new("store-dir") 89 | .help("the directory to store encrypted kbs2 records in") 90 | .short('s') 91 | .long("store-dir") 92 | .value_name("DIR") 93 | .value_parser(ValueParser::path_buf()) 94 | .default_value(>::as_ref( 95 | &kbs2::config::DEFAULT_STORE_DIR, 96 | )) 97 | .value_hint(ValueHint::DirPath), 98 | ) 99 | .arg( 100 | Arg::new("insecure-not-wrapped") 101 | .help("don't wrap the keypair with a master password") 102 | .long("insecure-not-wrapped") 103 | .action(ArgAction::SetTrue), 104 | ), 105 | ) 106 | .subcommand( 107 | Command::new("new") 108 | .about("create a new record") 109 | .arg( 110 | Arg::new("label") 111 | .help("the record's label") 112 | .index(1) 113 | .required(true), 114 | ) 115 | .arg( 116 | Arg::new("kind") 117 | .help("the kind of record to create") 118 | .short('k') 119 | .long("kind") 120 | .value_parser(PossibleValuesParser::new(kbs2::record::RECORD_KINDS)) 121 | .default_value("login"), 122 | ) 123 | .arg( 124 | Arg::new("force") 125 | .help("overwrite, if already present") 126 | .short('f') 127 | .long("force") 128 | .action(ArgAction::SetTrue), 129 | ) 130 | .arg( 131 | Arg::new("terse") 132 | .help("read fields in a terse format, even when connected to a tty") 133 | .short('t') 134 | .long("terse") 135 | .action(ArgAction::SetTrue), 136 | ) 137 | .arg( 138 | Arg::new("generator") 139 | .help("use the given generator to generate sensitive fields") 140 | .short('G') 141 | .long("generator") 142 | .default_value("default"), 143 | ), 144 | ) 145 | .subcommand( 146 | Command::new("list") 147 | .about("list records") 148 | .arg( 149 | Arg::new("details") 150 | .help("print (non-field) details for each record") 151 | .short('d') 152 | .long("details") 153 | .action(ArgAction::SetTrue), 154 | ) 155 | .arg( 156 | Arg::new("kind") 157 | .help("list only records of this kind") 158 | .short('k') 159 | .long("kind") 160 | .value_parser(PossibleValuesParser::new(kbs2::record::RECORD_KINDS)), 161 | ), 162 | ) 163 | .subcommand( 164 | Command::new("rm").about("remove one or more records").arg( 165 | Arg::new("label") 166 | .help("the labels of the records to remove") 167 | .index(1) 168 | .required(true) 169 | .num_args(1..), 170 | ), 171 | ) 172 | .subcommand( 173 | Command::new("rename") 174 | .about("rename a record") 175 | .arg( 176 | Arg::new("old-label") 177 | .help("the record's current label") 178 | .index(1) 179 | .required(true), 180 | ) 181 | .arg( 182 | Arg::new("new-label") 183 | .help("the new record label") 184 | .index(2) 185 | .required(true), 186 | ) 187 | .arg( 188 | Arg::new("force") 189 | .help("overwrite, if already present") 190 | .short('f') 191 | .long("force") 192 | .action(ArgAction::SetTrue), 193 | ), 194 | ) 195 | .subcommand( 196 | Command::new("dump") 197 | .about("dump one or more records") 198 | .arg( 199 | Arg::new("label") 200 | .help("the labels of the records to dump") 201 | .index(1) 202 | .required(true) 203 | .num_args(1..), 204 | ) 205 | .arg( 206 | Arg::new("json") 207 | .help("dump in JSON format (JSONL when multiple)") 208 | .short('j') 209 | .long("json") 210 | .action(ArgAction::SetTrue), 211 | ), 212 | ) 213 | .subcommand( 214 | Command::new("pass") 215 | .about("get the password in a login record") 216 | .arg( 217 | Arg::new("label") 218 | .help("the record's label") 219 | .index(1) 220 | .required(true), 221 | ) 222 | .arg( 223 | Arg::new("clipboard") 224 | .help("copy the password to the clipboard") 225 | .short('c') 226 | .long("clipboard") 227 | .action(ArgAction::SetTrue), 228 | ), 229 | ) 230 | .subcommand( 231 | Command::new("env") 232 | .about("get an environment record") 233 | .arg( 234 | Arg::new("label") 235 | .help("the record's label") 236 | .index(1) 237 | .required(true), 238 | ) 239 | .arg( 240 | Arg::new("value-only") 241 | .help("print only the environment variable value, not the variable name") 242 | .short('v') 243 | .long("value-only") 244 | .action(ArgAction::SetTrue), 245 | ) 246 | .arg( 247 | Arg::new("no-export") 248 | .help("print only VAR=val without `export`") 249 | .short('n') 250 | .long("no-export") 251 | .action(ArgAction::SetTrue), 252 | ), 253 | ) 254 | .subcommand( 255 | Command::new("edit") 256 | .about("modify a record with a text editor") 257 | .arg( 258 | Arg::new("label") 259 | .help("the record's label") 260 | .index(1) 261 | .required(true), 262 | ) 263 | .arg( 264 | Arg::new("preserve-timestamp") 265 | .help("don't update the record's timestamp") 266 | .short('p') 267 | .long("preserve-timestamp"), 268 | ), 269 | ) 270 | .subcommand( 271 | Command::new("generate") 272 | .about("generate secret values using a generator") 273 | .arg( 274 | Arg::new("generator") 275 | .help("the generator to use") 276 | .index(1) 277 | .default_value("default"), 278 | ), 279 | ) 280 | .subcommand( 281 | Command::new("rewrap") 282 | .about("change the master password on a wrapped key") 283 | .arg( 284 | Arg::new("no-backup") 285 | .help("don't make a backup of the old wrapped key") 286 | .short('n') 287 | .long("no-backup") 288 | .action(ArgAction::SetTrue), 289 | ) 290 | .arg( 291 | Arg::new("force") 292 | .help("overwrite a previous backup, if one exists") 293 | .short('f') 294 | .long("force") 295 | .action(ArgAction::SetTrue), 296 | ), 297 | ) 298 | .subcommand( 299 | // NOTE: The absence of a --force option here is intentional. 300 | Command::new("rekey") 301 | .about("re-encrypt the entire store with a new keypair and master password") 302 | .arg( 303 | Arg::new("no-backup") 304 | .help("don't make a backup of the old wrapped key, config, or store") 305 | .short('n') 306 | .long("no-backup") 307 | .action(ArgAction::SetTrue), 308 | ), 309 | ) 310 | .subcommand( 311 | Command::new("config") 312 | .subcommand_required(true) 313 | .about("interact with kbs2's configuration file") 314 | .subcommand( 315 | Command::new("dump") 316 | .about("dump the active configuration file as JSON") 317 | .arg( 318 | Arg::new("pretty") 319 | .help("pretty-print the JSON") 320 | .short('p') 321 | .long("pretty") 322 | .action(ArgAction::SetTrue), 323 | ), 324 | ), 325 | ) 326 | } 327 | 328 | fn run(matches: &ArgMatches, config: &kbs2::config::Config) -> Result<()> { 329 | // Subcommand dispatch happens here. All subcommands handled here take a `Config`. 330 | // 331 | // Internally, most (but not all) subcommands load a `Session` from their borrowed 332 | // `Config` argument. This `Session` is in turn used to perform record and encryption 333 | // operations. 334 | 335 | // Special case: `kbs2 agent` does not receive pre- or post-hooks. 336 | if let Some(("agent", matches)) = matches.subcommand() { 337 | return kbs2::command::agent(matches, config); 338 | } 339 | 340 | if let Some(pre_hook) = &config.pre_hook { 341 | log::debug!("pre-hook: {}", pre_hook); 342 | config.call_hook(pre_hook, &[])?; 343 | } 344 | 345 | match matches.subcommand() { 346 | Some(("new", matches)) => kbs2::command::new(matches, config)?, 347 | Some(("list", matches)) => kbs2::command::list(matches, config)?, 348 | Some(("rm", matches)) => kbs2::command::rm(matches, config)?, 349 | Some(("rename", matches)) => kbs2::command::rename(matches, config)?, 350 | Some(("dump", matches)) => kbs2::command::dump(matches, config)?, 351 | Some(("pass", matches)) => kbs2::command::pass(matches, config)?, 352 | Some(("env", matches)) => kbs2::command::env(matches, config)?, 353 | Some(("edit", matches)) => kbs2::command::edit(matches, config)?, 354 | Some(("generate", matches)) => kbs2::command::generate(matches, config)?, 355 | Some(("rewrap", matches)) => kbs2::command::rewrap(matches, config)?, 356 | Some(("rekey", matches)) => kbs2::command::rekey(matches, config)?, 357 | Some(("config", matches)) => kbs2::command::config(matches, config)?, 358 | Some((cmd, matches)) => { 359 | let cmd = format!("kbs2-{cmd}"); 360 | 361 | let ext_args: Vec<_> = match matches.get_many::("") { 362 | Some(values) => values.collect(), 363 | None => vec![], 364 | }; 365 | 366 | log::debug!("external command requested: {} (args: {:?})", cmd, ext_args); 367 | 368 | let status = process::Command::new(&cmd) 369 | .args(&ext_args) 370 | .env("KBS2_CONFIG_DIR", &config.config_dir) 371 | .env("KBS2_STORE", &config.store) 372 | .env("KBS2_SUBCOMMAND", "1") 373 | .env("KBS2_MAJOR_VERSION", env!("CARGO_PKG_VERSION_MAJOR")) 374 | .env("KBS2_MINOR_VERSION", env!("CARGO_PKG_VERSION_MINOR")) 375 | .env("KBS2_PATCH_VERSION", env!("CARGO_PKG_VERSION_PATCH")) 376 | .status() 377 | .with_context(|| format!("no such command: {cmd}"))?; 378 | 379 | if !status.success() { 380 | return Err(match status.code() { 381 | Some(code) => anyhow!("{} failed: exited with {}", cmd, code), 382 | None => anyhow!("{} failed: terminated by signal", cmd), 383 | }); 384 | } 385 | } 386 | _ => unreachable!(), 387 | } 388 | 389 | if let Some(post_hook) = &config.post_hook { 390 | log::debug!("post-hook: {}", post_hook); 391 | config.call_hook(post_hook, &[])?; 392 | } 393 | 394 | Ok(()) 395 | } 396 | 397 | fn main() -> Result<()> { 398 | env_logger::init(); 399 | 400 | let mut app = app(); 401 | let matches = app.clone().get_matches(); 402 | 403 | // Shell completion generation is completely independent, so perform it before 404 | // any config or subcommand operations. 405 | if let Some(shell) = matches.get_one::("completions") { 406 | generate(*shell, &mut app, env!("CARGO_PKG_NAME"), &mut io::stdout()); 407 | return Ok(()); 408 | } 409 | 410 | #[allow(clippy::unwrap_used)] 411 | let config_dir = matches.get_one::("config-dir").unwrap(); 412 | log::debug!("config dir: {:?}", config_dir); 413 | std::fs::create_dir_all(config_dir)?; 414 | 415 | // There are two special cases that are not handled in `run`: 416 | // 417 | // * `kbs2` (no subcommand): Act as if a long --help message was requested and exit. 418 | // * `kbs2 init`: We're initializing a config instead of loading one. 419 | if matches.subcommand().is_none() { 420 | return app 421 | .clone() 422 | .print_long_help() 423 | .with_context(|| "failed to print help".to_string()); 424 | } else if let Some(("init", matches)) = matches.subcommand() { 425 | return kbs2::command::init(matches, config_dir); 426 | } 427 | 428 | // Everything else (i.e., all other subcommands) go through here. 429 | let config = kbs2::config::load(config_dir)?; 430 | match run(&matches, &config) { 431 | Ok(()) => Ok(()), 432 | Err(e) => { 433 | if let Some(error_hook) = &config.error_hook { 434 | log::debug!("error-hook: {}", error_hook); 435 | config.call_hook(error_hook, &[&e.to_string()])?; 436 | } 437 | 438 | Err(e) 439 | } 440 | } 441 | } 442 | 443 | #[cfg(test)] 444 | mod tests { 445 | use super::*; 446 | 447 | #[test] 448 | fn test_app() { 449 | app().debug_assert(); 450 | } 451 | } 452 | -------------------------------------------------------------------------------- /tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | // NOTE(ww): Dead code allowed because of this `cargo test` bug: 2 | // https://github.com/rust-lang/rust/issues/46379 3 | #![allow(dead_code)] 4 | 5 | use std::process::Output; 6 | 7 | use assert_cmd::Command; 8 | use serde_json::Value; 9 | use tempfile::TempDir; 10 | 11 | #[derive(Debug)] 12 | pub struct CliSession { 13 | pub config_dir: TempDir, 14 | pub store_dir: TempDir, 15 | } 16 | 17 | impl CliSession { 18 | pub fn new() -> Self { 19 | let config_dir = TempDir::new().unwrap(); 20 | let store_dir = TempDir::new().unwrap(); 21 | 22 | // Run `kbs2 init` to configure the config and session directories. 23 | { 24 | kbs2() 25 | .arg("--config-dir") 26 | .arg(config_dir.path()) 27 | .arg("init") 28 | .arg("--insecure-not-wrapped") 29 | .arg("--store-dir") 30 | .arg(store_dir.path()) 31 | .assert() 32 | .success(); 33 | } 34 | 35 | Self { 36 | config_dir, 37 | store_dir, 38 | } 39 | } 40 | 41 | pub fn command(&self) -> Command { 42 | let mut kbs2 = kbs2(); 43 | 44 | kbs2.arg("--config-dir").arg(self.config_dir.path()); 45 | 46 | kbs2 47 | } 48 | } 49 | 50 | pub fn kbs2() -> Command { 51 | Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap() 52 | } 53 | 54 | pub trait ToJson { 55 | fn json(&self) -> Value; 56 | } 57 | 58 | impl ToJson for Output { 59 | fn json(&self) -> Value { 60 | serde_json::from_slice(&self.stdout).unwrap() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/test_kbs2.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use clap::ValueEnum; 4 | use clap_complete::Shell; 5 | use common::kbs2; 6 | 7 | #[test] 8 | fn test_kbs2_help() { 9 | // `help`, `--help`, and `-h` all produce the same output 10 | 11 | let reference_output = kbs2().arg("help").output().unwrap(); 12 | assert!(reference_output.status.success()); 13 | 14 | for help in &["--help", "-h"] { 15 | let output = kbs2().arg(help).output().unwrap(); 16 | assert!(output.status.success()); 17 | assert_eq!(reference_output.stdout, output.stdout); 18 | } 19 | } 20 | 21 | #[test] 22 | fn test_kbs2_completions() { 23 | // Tab completion generation works 24 | 25 | for shell in Shell::value_variants() { 26 | let output = kbs2() 27 | .args(["--completions", &shell.to_string()]) 28 | .output() 29 | .unwrap(); 30 | assert!(output.status.success()); 31 | assert!(!output.stdout.is_empty()); 32 | } 33 | } 34 | 35 | #[test] 36 | fn test_kbs2_version() { 37 | // kbs2 --version works and outputs a string starting with `kbs2 X.Y.Z` 38 | 39 | let version = format!("kbs2 {}", env!("CARGO_PKG_VERSION")); 40 | 41 | let output = kbs2().arg("--version").output().unwrap(); 42 | assert!(output.status.success()); 43 | assert!(String::from_utf8(output.stdout) 44 | .unwrap() 45 | .starts_with(&version)); 46 | } 47 | -------------------------------------------------------------------------------- /tests/test_kbs2_init.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::CliSession; 4 | 5 | #[test] 6 | fn test_kbs2_init() { 7 | let session = CliSession::new(); 8 | 9 | let config_dir = session.config_dir.path(); 10 | let store_dir = session.store_dir.path(); 11 | 12 | // Our config dir, etc. all exist; the store dir is empty. 13 | assert!(config_dir.is_dir()); 14 | assert!(store_dir.is_dir()); 15 | assert!(config_dir.join("config.toml").is_file()); 16 | assert!(store_dir.read_dir().unwrap().next().is_none()); 17 | } 18 | -------------------------------------------------------------------------------- /tests/test_kbs2_new.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::{CliSession, ToJson}; 4 | use serde_json::json; 5 | 6 | // TODO: Figure out how to test prompts instead of terse inputs. 7 | 8 | #[test] 9 | fn test_kbs2_new_login() { 10 | let session = CliSession::new(); 11 | 12 | session 13 | .command() 14 | .args(["new", "-k", "login", "test-record"]) 15 | .write_stdin("fakeuser\x01fakepass") 16 | .assert() 17 | .success(); 18 | 19 | let dump = session 20 | .command() 21 | .args(["dump", "--json", "test-record"]) 22 | .output() 23 | .unwrap() 24 | .json(); 25 | 26 | let fields = dump.get("body").unwrap().get("fields").unwrap(); 27 | 28 | assert_eq!( 29 | fields, 30 | // https://github.com/serde-rs/json/issues/867 31 | &json!({ "username": "fakeuser", "password": "fakepass" }), 32 | ); 33 | } 34 | 35 | #[test] 36 | fn test_kbs2_new_environment() { 37 | let session = CliSession::new(); 38 | 39 | session 40 | .command() 41 | .args(["new", "-k", "environment", "test-record"]) 42 | .write_stdin("fakevariable\x01fakevalue") 43 | .assert() 44 | .success(); 45 | 46 | let dump = session 47 | .command() 48 | .args(["dump", "--json", "test-record"]) 49 | .output() 50 | .unwrap() 51 | .json(); 52 | 53 | let fields = dump.get("body").unwrap().get("fields").unwrap(); 54 | 55 | assert_eq!( 56 | fields, 57 | // https://github.com/serde-rs/json/issues/867 58 | &json!({ "variable": "fakevariable", "value": "fakevalue" }), 59 | ); 60 | } 61 | 62 | #[test] 63 | fn test_kbs2_new_unstructured() { 64 | let session = CliSession::new(); 65 | 66 | session 67 | .command() 68 | .args(["new", "-k", "unstructured", "test-record"]) 69 | .write_stdin("fakevalue") 70 | .assert() 71 | .success(); 72 | 73 | let dump = session 74 | .command() 75 | .args(["dump", "--json", "test-record"]) 76 | .output() 77 | .unwrap() 78 | .json(); 79 | 80 | let fields = dump.get("body").unwrap().get("fields").unwrap(); 81 | 82 | assert_eq!( 83 | fields, 84 | // https://github.com/serde-rs/json/issues/867 85 | &json!({ "contents": "fakevalue" }), 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /tests/test_kbs2_rename.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::CliSession; 4 | 5 | #[test] 6 | fn test_kbs2_rename() { 7 | let session = CliSession::new(); 8 | 9 | // `rename` deletes the old record. 10 | session 11 | .command() 12 | .args(["new", "-k", "login", "test-record"]) 13 | .write_stdin("fakeuser\x01fakepass") 14 | .assert() 15 | .success(); 16 | 17 | session 18 | .command() 19 | .args(["rename", "test-record", "test-record-1"]) 20 | .assert() 21 | .success(); 22 | 23 | session 24 | .command() 25 | .args(["dump", "test-record"]) 26 | .assert() 27 | .failure(); 28 | 29 | session 30 | .command() 31 | .args(["dump", "test-record-1"]) 32 | .assert() 33 | .success(); 34 | } 35 | 36 | // TODO: `kbs2 rename --force` 37 | // TODO: `kbs2 rename` with the same record twice 38 | -------------------------------------------------------------------------------- /tests/test_kbs2_rm.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::CliSession; 4 | 5 | #[test] 6 | fn test_kbs2_rm() { 7 | let session = CliSession::new(); 8 | 9 | // `kbs2 rm` with a nonexistent record fails. 10 | { 11 | session 12 | .command() 13 | .args(["rm", "does-not-exist"]) 14 | .assert() 15 | .failure(); 16 | } 17 | 18 | // `kbs2 rm` works as expected with a record that exists. 19 | { 20 | session 21 | .command() 22 | .args(["new", "-k", "login", "test-record"]) 23 | .write_stdin("fakeuser\x01fakepass") 24 | .assert() 25 | .success(); 26 | 27 | session 28 | .command() 29 | .args(["rm", "test-record"]) 30 | .assert() 31 | .success(); 32 | 33 | session 34 | .command() 35 | .args(["dump", "test-record"]) 36 | .assert() 37 | .failure(); 38 | } 39 | 40 | // `kbs2 rm` works as expected with multiple records. 41 | { 42 | session 43 | .command() 44 | .args(["new", "-k", "login", "test-record-1"]) 45 | .write_stdin("fakeuser\x01fakepass") 46 | .assert() 47 | .success(); 48 | 49 | session 50 | .command() 51 | .args(["new", "-k", "login", "test-record-2"]) 52 | .write_stdin("fakeuser\x01fakepass") 53 | .assert() 54 | .success(); 55 | 56 | session 57 | .command() 58 | .args(["rm", "test-record-1", "test-record-2"]) 59 | .assert() 60 | .success(); 61 | 62 | session 63 | .command() 64 | .args(["dump", "test-record-1", "test-record-2"]) 65 | .assert() 66 | .failure(); 67 | } 68 | } 69 | --------------------------------------------------------------------------------