├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── config.ini.example ├── docs ├── configure.md ├── install.md └── usage.md ├── flake.lock ├── flake.nix ├── keepmenu.1 ├── keepmenu.1.md ├── keepmenu ├── __init__.py ├── __main__.py ├── edit.py ├── keepmenu.py ├── menu.py ├── tokens_dotool.py ├── tokens_pynput.py ├── tokens_wtype.py ├── tokens_xdotool.py ├── tokens_ydotool.py ├── totp.py ├── type.py └── view.py ├── pyproject.toml ├── requirements.txt └── tests ├── keepmenu-config.ini ├── test.kdbx └── tests.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 # Ensures version gets set correctly 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: "3.x" 25 | 26 | - name: Install testing dependencies 27 | run: | 28 | sudo apt update 29 | sudo apt install -y suckless-tools xdotool 30 | 31 | - name: Install Hatch 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install hatch hatchling hatch-vcs 35 | 36 | - name: Build package 37 | run: hatch build 38 | 39 | - name: Test package 40 | run: hatch run make test 41 | 42 | - name: Store the distribution packages 43 | uses: actions/upload-artifact@v4 44 | with: 45 | name: keepmenu 46 | path: dist/ 47 | 48 | publish-to-pypi: 49 | name: Publish to PyPI 50 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 51 | needs: 52 | - build 53 | runs-on: ubuntu-latest 54 | environment: 55 | name: pypi 56 | url: https://pypi.org/p/keepmenu # Replace with your PyPI project name 57 | permissions: 58 | id-token: write # IMPORTANT: mandatory for trusted publishing 59 | 60 | steps: 61 | - name: Download all the dists 62 | uses: actions/download-artifact@v4 63 | with: 64 | name: keepmenu 65 | path: dist/ 66 | - name: Publish to PyPI 67 | uses: pypa/gh-action-pypi-publish@release/v1 68 | 69 | github-release: 70 | name: >- 71 | Sign the with Sigstore and upload to GitHub Release 72 | needs: 73 | - publish-to-pypi 74 | runs-on: ubuntu-latest 75 | 76 | permissions: 77 | contents: write # IMPORTANT: mandatory for making GitHub Releases 78 | id-token: write # IMPORTANT: mandatory for sigstore 79 | 80 | steps: 81 | - name: Check out the repository 82 | uses: actions/checkout@v4 83 | with: 84 | fetch-depth: 0 # Fetch all tags and history 85 | - name: Get tag annotation 86 | id: tag_annotation 87 | run: | 88 | # Extract the tag annotation for release notes 89 | tag_annotation=$(git for-each-ref --format '%(contents:body)' refs/tags/${{ github.ref_name }}) 90 | echo "annotation=$tag_annotation" >> $GITHUB_ENV 91 | - name: Download all the dists 92 | uses: actions/download-artifact@v4 93 | with: 94 | name: keepmenu 95 | path: dist/ 96 | - name: Sign the dists with Sigstore 97 | uses: sigstore/gh-action-sigstore-python@v3.0.0 98 | with: 99 | inputs: >- 100 | ./dist/*.tar.gz 101 | ./dist/*.whl 102 | - name: Create GitHub Release 103 | env: 104 | GITHUB_TOKEN: ${{ github.token }} 105 | run: >- 106 | gh release create 107 | '${{ github.ref_name }}' 108 | --repo '${{ github.repository }}' 109 | --title '${{ github.ref_name }}' 110 | --notes "${{ env.annotation }}" 111 | - name: Upload artifact signatures to GitHub Release 112 | env: 113 | GITHUB_TOKEN: ${{ github.token }} 114 | # Upload to GitHub Release using the `gh` CLI. 115 | # `dist/` contains the built packages, and the 116 | # sigstore-produced signatures and certificates. 117 | run: >- 118 | gh release upload 119 | '${{ github.ref_name }}' dist/** 120 | --repo '${{ github.repository }}' 121 | 122 | publish-to-testpypi: 123 | name: Publish to TestPyPI 124 | needs: 125 | - build 126 | runs-on: ubuntu-latest 127 | 128 | environment: 129 | name: testpypi 130 | url: https://test.pypi.org/p/keepmenu 131 | 132 | permissions: 133 | id-token: write # IMPORTANT: mandatory for trusted publishing 134 | 135 | steps: 136 | - name: Download all the dists 137 | uses: actions/download-artifact@v4 138 | with: 139 | name: keepmenu 140 | path: dist/ 141 | - name: Publish to TestPyPI 142 | uses: pypa/gh-action-pypi-publish@release/v1 143 | with: 144 | repository-url: https://test.pypi.org/legacy/ 145 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .envrc 3 | .direnv/ 4 | .venv/ 5 | *.egg-info/ 6 | *.pyc 7 | *.un~ 8 | build/ 9 | dist/ 10 | keepmenu/_version.py 11 | result/ 12 | tests/.test.kdbx.lock 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VENV = .venv 2 | PYTHON = $(VENV)/bin/python 3 | PIP = $(VENV)/bin/pip 4 | 5 | all: venv 6 | 7 | $(VENV)/bin/activate: requirements.txt 8 | python3 -m venv $(VENV) 9 | $(PIP) install -U pip wheel 10 | $(PIP) install . 11 | 12 | venv: $(VENV)/bin/activate 13 | 14 | run: venv 15 | $(VENV)/bin/keepmenu 16 | 17 | clean: 18 | rm -rf __pycache__ 19 | rm -rf $(VENV) 20 | 21 | man: keepmenu.1.md 22 | pandoc keepmenu.1.md -s -t man -o keepmenu.1 23 | 24 | test: venv 25 | $(PYTHON) tests/tests.py 26 | 27 | .PHONY: all venv run clean 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Keepmenu 2 | 3 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/keepmenu) 4 | ![PyPI](https://img.shields.io/pypi/v/keepmenu) 5 | ![GitHub contributors](https://img.shields.io/github/contributors/firecat53/keepmenu) 6 | 7 | Fully featured [Bemenu][7]/Dmenu/[Wmenu][14]/[Fuzzel][13]/[Rofi][2]/[Tofi][15]/[Wofi][8]/[Yofi][9] frontend for 8 | autotype and managing of Keepass databases. 9 | 10 | Inspired in part by [Passhole][3], but more dmenu and less command line focused. 11 | 12 | ## Installation 13 | 14 | `pip install --user keepmenu` 15 | 16 | Ensure `~/.local/bin` is in your `$PATH`. Run `keepmenu` and enter your database 17 | path, keyfile path, and password. 18 | 19 | For full installation documention see the [installation docs][docs/install.md]. 20 | 21 | ## Full Documentation 22 | 23 | [Installation](docs/install.md) - [Configuration](docs/configure.md) - [Usage](docs/usage.md) 24 | 25 | ## Requirements 26 | 27 | 1. Python 3.7+ 28 | 2. [Pykeepass][1] >= 4.0.0 and [pynput][5] 29 | 3. Bemenu, Dmenu, Wmenu, Fuzzel, Rofi, Tofi, Wofi, or Yofi 30 | 4. xsel or wl-copy 31 | 5. (optional) Pinentry 32 | 6. (optional) xdotool (for X), [ydotool][10] or [wtype][11](for Wayland), [dotool][12] (X or Wayland). 33 | 34 | ## Features 35 | 36 | - Supports .kdbx databases, not .kdb. 37 | - Auto-type username and/or password on selection. Select to clipboard if 38 | desired (clears clipboard after 30s on X11 or after 1 paste on Wayland). 39 | - Background process allows selectable time-out for locking the database. 40 | - Multiple databases can be unlocked and switched on the fly. 41 | - Use a custom [Keepass 2.x style auto-type sequence][6]. 42 | - Type, view or edit any field. 43 | - Open the URL in the default web browser. 44 | - Edit notes using terminal or gui editor. 45 | - Add and Delete entries. 46 | - Add, delete, rename and move groups. 47 | - Hide selected groups from the default and 'View/Type Individual entries' views. 48 | - Configure the characters and groups of characters used during password 49 | generation. 50 | - Optional Pinentry support for secure passphrase entry. 51 | - [Keepass field references][4] are supported. 52 | - Display and manage expired passwords. 53 | - Add, edit and type TOTP codes. 54 | - Add, edit, type and delete custom attributes. 55 | 56 | ## License 57 | 58 | - GPLv3 59 | 60 | ## Usage 61 | 62 | `keepmenu [-h] [-a AUTOTYPE] [-c CONF_FILE] [-C] [-d DATABASE] [-k KEY_FILE] [-t]` 63 | 64 | - Run `keepmenu` or bind to keystroke combination. 65 | - Enter database path on first run. 66 | - Start typing to match entries. 67 | - [Configure](docs/configure.md) config.ini as desired. 68 | - More detailed [usage information](docs/usage.md). 69 | 70 | ## Tests 71 | 72 | To run tests in a venv: `make test` 73 | 74 | ## Development 75 | 76 | - To install keepmenu in a venv: `make` 77 | - Build man page from Markdown source: `make man` 78 | - Using `hatch`: 79 | - `hatch shell`: provides venv with editable installation. 80 | - `hatch build` && `hatch publish`: build and publish to Pypi. 81 | - Using `nix`: 82 | - `nix develop`: Provides development shell/venv with all dependencies. 83 | - `make test` and `hatch build/publish` work as usual. 84 | - GitHub Action will upload to TestPyPi on each push to `main`. To create a 85 | GitHub and PyPi release, create a new tag (formatting below) and push tags. 86 | 87 | 88 | 89 | * Release note 1 90 | * Release note 2 91 | * ... 92 | 93 | [1]: https://github.com/pschmitt/pykeepass "Pykeepass" 94 | [2]: https://davedavenport.github.io/rofi/ "Rofi" 95 | [3]: https://github.com/purduelug/passhole "Passhole" 96 | [4]: https://keepass.info/help/base/fieldrefs.html "Keepass field references" 97 | [5]: https://github.com/moses-palmer/pynput "pynput" 98 | [6]: https://keepass.info/help/base/autotype.html#autoseq "Keepass 2.x codes" 99 | [7]: https://github.com/Cloudef/bemenu "Bemenu" 100 | [8]: https://hg.sr.ht/~scoopta/wofi "Wofi" 101 | [9]: https://github.com/l4l/yofi "Yofi" 102 | [10]: https://github.com/ReimuNotMoe/ydotool/ "Ydotool" 103 | [11]: https://github.com/atx/wtype "Wtype" 104 | [12]: https://git.sr.ht/~geb/dotool "Dotool" 105 | [13]: https://codeberg.org/dnkl/fuzzel "Fuzzel" 106 | [14]: https://git.sr.ht/~adnano/wmenu "wmenu" 107 | [15]: https://github.com/philj56/tofi "Tofi" 108 | -------------------------------------------------------------------------------- /config.ini.example: -------------------------------------------------------------------------------- 1 | [dmenu] 2 | # dmenu_command = /usr/bin/dmenu 3 | # # Note that dmenu_command can contain arguments as well like: 4 | # # `dmenu_command = rofi -dmenu -theme keepmenu -password` 5 | # # `dmenu_command = rofi -dmenu -width 30 -password -i` 6 | # # `dmenu_command = dmenu -i -l 25 -b -nb #222222 -nf #222222` 7 | # pinentry = Pinentry command 8 | # title_path = , or . Length of database path to display. 9 | 10 | [dmenu_passphrase] 11 | # # Uses the -password flag for Rofi. For dmenu, sets -nb and -nf to the same color. 12 | # obscure = True 13 | # obscure_color = #222222 14 | 15 | [database] 16 | # database_1 = ~ for $HOME is ok 17 | # keyfile_1 = 18 | # password_1 = database password **INSECURE** 19 | # password_cmd_2 = 20 | # database_2 = 21 | # # Override autotype default from database_2 22 | # autotype_default_2 = {TOTP}{ENTER} 23 | # etc.... 24 | # pw_cache_period_min = 25 | 26 | ## Set 'gui_editor' for: emacs, gvim, leafpad 27 | ## Set 'editor' for terminal editors: vim, emacs -nw, nano 28 | ## Set 'terminal' if using a terminal editor 29 | # editor = 'vim' by default 30 | # terminal = . 'xterm' by default 31 | # gui_editor = e.g. gui_editor = gvim -f 32 | # type_library = pynput (default), xdotool (for alternate keyboard layout support), ydotool (for Wayland), or wtype (also for Wayland) 33 | # hide_groups = Recycle Bin 34 | # Group 2 35 | # Group 3 36 | 37 | ## Set the default autotype sequence (https://keepass.info/help/base/autotype.html#autoseq) 38 | # autotype_default = {USERNAME}{TAB}{PASSWORD}{ENTER} 39 | # type_url = Default False. When True, types instead of opens the URL entry 40 | 41 | [password_chars] 42 | # Set custom groups of characters for password generation. Any name is fine and 43 | # these can be used to create new groups of presets in password_char_presets. If 44 | # you reuse 'upper', 'lower', 'digits', or 'punctuation', those will 45 | # replace the default values. 46 | # Defaults: 47 | # lower = abcdefghijklmnopqrstuvwxyz 48 | # upper = ABCDEFGHIJKLMNOPQRSTUVWXYZ 49 | # digits = 0123456789 50 | # NOTE: remember that % needs to be escaped with another % sign 51 | # punctuation = !"#$%%&'()*+,-./:;<=>?@[\]^_`{|}~ 52 | # EXAMPLES: 53 | # punc min = !?#*@-+$%% 54 | # upper = ABCDEFZ 55 | 56 | [password_char_presets] 57 | # Set character preset groups for password generation. For multiple sets use a space in between 58 | # If you set any custom presets here, the default sets will not be displayed unless uncommented below: 59 | # Valid values are: upper lower digits punctuation 60 | # Also valid are any custom sets defined in [password_chars] 61 | # Defaults: 62 | # Letters+Digits+Punctuation = upper lower digits punctuation 63 | # Letters+Digits = upper lower digits 64 | # Letters = upper lower 65 | # Digits = digits 66 | # Custom Examples: 67 | # Minimal Punc = upper lower digits "punc min" 68 | # Router Site = upper digits 69 | -------------------------------------------------------------------------------- /docs/configure.md: -------------------------------------------------------------------------------- 1 | # Keepmenu Configuration 2 | 3 | [Installation](install.md) - [Usage](usage.md) 4 | 5 | If you start keepmenu for the first time without a config file, it will prompt 6 | you for database and keyfile locations and save them in a default config file. 7 | 8 | OR Copy config.ini.example to ~/.config/keepmenu/config.ini and use it as a 9 | reference for additional options. 10 | 11 | Alternatively you can specify the file path to your config.ini using the -c/--config flag. 12 | 13 | #### Config.ini values 14 | 15 | | Section | Key | Default | Notes | 16 | |---------------------------|------------------------------|-----------------------------------------|--------------------------------------------------------------| 17 | | `[dmenu]` | `dmenu_command` | `dmenu` | Command can include arguments | 18 | | | `pinentry` | None | | 19 | | | `title_path` | `True` | True, False or int | 20 | | `[dmenu_passphrase]` | `obscure` | `False` | | 21 | | | `obscure_color` | `#222222` | Only applicable to dmenu | 22 | | `[database]` | `database_n` | None | `n` is any integer | 23 | | | `keyfile_n` | None | | 24 | | | `password_n` | None | | 25 | | | `password_cmd_n` | None | | 26 | | | `autotype_default_n` | None | Overrides global default | 27 | | | `pw_cache_period_min` | `360` | Value in minutes | 28 | | | `editor` | `vim` | | 29 | | | `terminal` | `xterm` | | 30 | | | `gui_editor` | None | | 31 | | | `type_library` | `pynput` | xdotool, ydotool, wtype or pynput | 32 | | | `hide_groups` | None | See below for formatting of multiple groups | 33 | | | `autotype_default` | `{USERNAME}{TAB}{PASSWORD}{ENTER}` | [Keepass autotype sequences][1] | 34 | | | `type_url` | `False` | | 35 | | `[password_chars]` | `lower` | `abcdefghijklmnopqrstuvwxyz` | | 36 | | | `upper` | `ABCDEFGHIJKLMNOPQRSTUVWXYZ` | | 37 | | | `digits` | `0123456789` | | 38 | | | `punctuation` | ``!"#$%%&'()*+,-./:;<=>?@[\]^_`{│}~`` | | 39 | | | `Custom Name(s)` | `Any string` | | 40 | | `[password_char_presets]` | `Letters+Digits+Punctuation` | `upper lower digits punctuation` | | 41 | | | `Letters+Digits` | `upper lower digits` | | 42 | | | `Letters` | `upper lower` | | 43 | | | `Digits` | `digits` | | 44 | | | `Custom Name(s)` | `Any combo of [password_chars] entries` | | 45 | 46 | #### Config.ini example 47 | 48 | [dmenu] 49 | # Note that dmenu_command can contain arguments as well 50 | dmenu_command = rofi -dmenu -theme keepmenu -i 51 | # dmenu_command = dmenu -i -l 25 -b -nb #909090 -nf #303030 52 | pinentry = pinentry-gtk 53 | title_path = 25 54 | 55 | [dmenu_passphrase] 56 | ## Obscure password entry. 57 | obscure = True 58 | obscure_color = #303030 59 | 60 | [database] 61 | database_1 = ~/docs/Passwords.kdbx 62 | keyfile_1 = /mnt/usb/keyfile 63 | database_2 = ~/docs/totp_db.kdbx 64 | autotype_default_2 = {TOTP}{ENTER} 65 | password_cmd_2 = gpg -qd ~/.pass.gpg 66 | 67 | pw_cache_period_min = 720 68 | 69 | gui_editor = gvim -f 70 | type_library = xdotool 71 | hide_groups = Recycle Bin 72 | Group 2 73 | Group 3 74 | type_url = True 75 | 76 | ## Set the global default 77 | autotype_default = {USERNAME}{TAB}{PASSWORD}{ENTER} 78 | 79 | [password_chars] 80 | # Set custom groups of characters for password generation. Any name is fine and 81 | # these can be used to create new groups of presets in password_char_presets. If 82 | # you reuse 'upper', 'lower', 'digits', or 'punctuation', those will 83 | # replace the default values. 84 | lower = abcdefghjkmnpqrstuvwxyz 85 | upper = ABCDEFGHJKMNPQRSTUVWXYZ 86 | digits = 23456789 87 | punctuation = !"#$%%&'()*+,-./:;<=>?@[\]^_`{}~ 88 | # NOTE: % needs to be escaped with another % sign 89 | # Custom EXAMPLES: 90 | punc min = !?#*@-+$%% 91 | upper = ABCDEFZ 92 | 93 | [password_char_presets] 94 | # Set character preset groups for password generation. For multiple sets use a space in between 95 | # If you set any custom presets here, the default sets will not be displayed unless uncommented below: 96 | # Valid values are: upper lower digits punctuation 97 | # Also valid are any custom sets defined in [password_chars] 98 | # Custom Examples: 99 | Minimal Punc = upper lower digits "punc min" 100 | Router Site = upper digits 101 | 102 | 1. Add your database(s) and keyfile(s) 103 | 2. Adjust `pw_cache_period_min` if desired. Default is 6 hours (360 min). 104 | 3. Set the dmenu_command to the desired application, including configuration 105 | options. 106 | - *Note:* If using wofi, the `--height` paramater will not work properly. You 107 | will have to set `--lines` instead, as keepmenu attempts to set a dynamic 108 | height based on number of lines of options. 109 | - If using Rofi, pass desired theme via `dmenu_command = rofi -theme 110 | .rasi`. 111 | - Dmenu theme options are also passed in `dmenu_command` 112 | 5. Adjust the `autotype_default`, if desired. Allowed codes are the [Keepass 2.x 113 | codes][1] except for repetitions and most command codes. `{DELAY x}` 114 | (in milliseconds) is supported. Individual autotype sequences can be edited 115 | or disabled inside Keepmenu. 116 | 6. If you need support on Wayland for non-U.S. English keyboard layouts and/or 117 | characters, you might need to experiment with the various typing options to 118 | which works for your use case. 119 | 120 | * When using xdotool, call `setxkbmap` to set your keyboard type somewhere 121 | in your window manager or desktop environment initialization. For example: 122 | `exec setxkbmap de` in ~/.config/i3/config. 123 | 124 | 7. New sets of characters can be set in config.ini in the `[password_chars]` 125 | section. A new preset for each custom set will be listed in addition to the 126 | default presets. If you redefine one of the default sets (upper, lower, 127 | digits, punctuation), it will replace the default values. 128 | 8. New preset groups of character sets can be defined in config.ini in the 129 | `[password_char_presets]` section. You can set any combination of default and 130 | custom character sets. A minimum of one character from each distinct set will 131 | be used when generating a new password. If any custom presets are defined, 132 | the default presets will not be displayed unless they are uncommented. 133 | 134 | **Warning** If you choose to store your database password into config.ini, make 135 | sure to `chmod 600 config.ini`. This is not secure and I only added it as a 136 | convenience for testing. 137 | 138 | [1]: https://keepass.info/help/base/autotype.html#autoseq "Keepass Autotype Sequences" 139 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Keepmenu Installation 2 | 3 | [Configuration](configure.md) - [Usage](usage.md) 4 | 5 | ## Requirements 6 | 7 | 1. Python 3.7+. 8 | 2. [Pykeepass][1] >= 4.0.0 and [pynput][2]. Install via pip or your 9 | distribution's package manager, if available. 10 | 3. Bemenu, Dmenu, Wmenu, Fuzzel, Rofi, Tofi, Wofi, or Yofi. 11 | 4. (optional) Pinentry. Make sure to set which flavor of pinentry command to use 12 | in the config file. 13 | 5. (optional) xdotool (for X) or ydotool (>=1.0.0, for Wayland), wtype (for 14 | Wayland), dotool (X or Wayland). If you have a lot of Unicode characters or 15 | use a non-U.S. English keyboard layout, you might have to experiment with 16 | these to determine which works properly for your use case. 17 | 18 | #### Archlinux 19 | 20 | `$ sudo pacman -S python-pip dmenu` 21 | 22 | #### Fedora 38 23 | 24 | `$ sudo dnf install python3-devel dmenu` 25 | 26 | #### Ubuntu 22.10 27 | 28 | Ensure Universe repository is enabled. 29 | 30 | `$ sudo apt install python3-pip suckless-tools` 31 | 32 | ## Install (recommended) 33 | 34 | `$ pip install --user keepmenu` 35 | 36 | Add ~/.local/bin to $PATH 37 | 38 | ### Install (virtualenv) 39 | 40 | $ python -m venv venv 41 | $ source venv/bin/activate 42 | $ pip install keepmenu 43 | 44 | Link to the executable `venv/bin/keemenu` when assigning a keyboard shortcut. 45 | 46 | ### Install (virtualenv) from git 47 | 48 | $ git clone https://github.com/firecat53/keepmenu 49 | $ cd keepmenu 50 | $ make 51 | $ make run OR ./venv/bin/keepmenu 52 | 53 | ### Install (git) 54 | 55 | $ git clone https://github.com/firecat53/keepmenu 56 | $ cd keepmenu 57 | $ git checkout (if desired) 58 | $ pip install --user . OR 59 | $ pip install --user -e . (for editable install) 60 | 61 | ### Available in [Archlinux AUR][1] and in Nix packages 62 | 63 | 64 | ## Wayland Notes 65 | 66 | - Dmenu and Rofi will work under XWayland on wlroots based compositors such as Sway. 67 | - The only combination I've found that works on Gnome/Wayland is Wofi with ydotool. 68 | - To enable ydotool to work without sudo 69 | - Pick a group that one or more users 70 | belong to (e.g. `users`) and: 71 | 72 | $ echo "KERNEL==\"uinput\", GROUP=\"users\", MODE=\"0660\", \ 73 | OPTIONS+=\"static_node=uinput\"" | sudo tee \ 74 | /etc/udev/rules.d/80-uinput.rules > /dev/null 75 | # udevadm control --reload-rules && udevadm trigger 76 | 77 | - Create a systemd user service for ydotoold: 78 | 79 | ~/.config/systemd/user/ydotoold.service 80 | [Unit] 81 | Description=ydotoold Service 82 | 83 | [Service] 84 | ExecStart=/usr/bin/ydotoold 85 | 86 | [Install] 87 | WantedBy=default.target 88 | 89 | - Enable and start ydotoold.service: 90 | 91 | $ systemctl --user daemon-reload 92 | $ systemctl --user enable --now ydotoold.service 93 | 94 | ### Wayland compatibility 95 | 96 | | | X | Wayland(wlroots) & Xwayland | Pure wlroots Wayland | Gnome/Wayland(2) | Unicode Support | 97 | |----------------|-----|-----------------------------|----------------------|------------------|-----------------| 98 | | *Launchers* | | | | | | 99 | | Dmenu | Yes | No | No | No | | 100 | | Fuzzel | No | Yes | Yes | No | | 101 | | Rofi | Yes | Yes | No | No | | 102 | | Bemenu | Yes | Yes | Yes | No | | 103 | | Tofi | No | Yes | Yes | No | | 104 | | Wofi | No | Yes | Yes | Yes | | 105 | | Yofi | No | Yes | Yes | Yes | | 106 | | *Typing Tools* | | | | | | 107 | | Pynput | Yes | No | No | No | No | 108 | | Xdotool | Yes | No | No | No | Yes | 109 | | Ydotool (1) | Yes | Yes | Yes | Yes | No (3) | 110 | | Wtype | No | Yes | Yes | No | Yes | 111 | | dotool | Yes | Yes | Yes | Yes | Yes | 112 | 113 | (1) Ydotool [doesn't correctly type](https://github.com/ReimuNotMoe/ydotool/issues/186) 114 | some special characters. 115 | 116 | (2) Gnome `modal` dialogs for SSH/GPG key entries are unusable with any password 117 | manager that performs autotyping. You have to copy the password to the clipboard 118 | before you need it and paste into the field because the dialog does not allow 119 | you to navigate away once it's open. 120 | 121 | (3) Supposedly you can change the keyboard language of the ydotool virtual 122 | device in the Sway config to enable support for characters on that keyboard but 123 | I have not tested that. 124 | 125 | [1]: https://aur.archlinux.org/packages/keepmenu-git "Archlinux AUR" 126 | [2]: https://github.com/moses-palmer/pynput "pynput" 127 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Keepmenu Usage 2 | 3 | [Installation](install.md) - [Configuration](configure.md) 4 | 5 | ## Basic 6 | 7 | - [Configure](configure.md) config.ini as desired. 8 | - Run `keepmenu` or bind to keystroke combination. 9 | - Enter database/keyfile paths on first run if not already configured in config.ini. 10 | - Start typing to match entries, `Enter` to type with default autotype sequence 11 | `{USERNAME}{TAB}{PASSWORD}{ENTER}`. 12 | 13 | ## CLI Options 14 | 15 | `keepmenu [-h] [-a AUTOTYPE] [-c CONF_FILE] [-C] [-d DATABASE] [-k KEY_FILE]` 16 | 17 | --help, -h Output a usage message and exit. 18 | 19 | -a AUTOTYPE, --autotype AUTOTYPE Override autotype sequence in config.ini 20 | 21 | -c CONF_FILE, --config CONF_FILE File path to a config file 22 | 23 | -C --clipboard, type to clipboard 24 | 25 | -d DATABASE, --database DATABASE File path to a database to open, skipping the database selection menu 26 | 27 | -k KEY_FILE, --keyfile KEY_FILE File path of the keyfile needed to open the database specified by --database/-d 28 | 29 | ## Features 30 | 31 | - *General features* 32 | - Open or create .kdbx databases, not .kdb. 33 | - Switch databases on the fly. 34 | - Alternate keyboard languages and layouts supported via xdotool or ydotool (for 35 | Wayland) 36 | - Display of expiring/expired passwords and shows the expiration time where set 37 | - Add, edit and type TOTP codes. RFC 6238, Steam and custom settings are supported. 38 | Supports TOTP attributes generated by [KeePass2][3], as well as [KeeOtp][4] and [TrayTOTP][5] plugins' [formats][6]. 39 | - *Type entries* 40 | - Auto-type username and/or password on selection. 41 | - Select to clipboard if desired (clears clipboard after 30s on X11 or after 42 | 1 paste on Wayland). If `view/type individual entries` isn't selected 43 | first, it will copy the password field to the clipboard if it exists, 44 | otherwise will raise an error. 45 | - Use a custom [Keepass 2.x style auto-type sequence][1] if you have one defined 46 | (except for character repetition and the 'special commands'). Set it per entry 47 | or set a global default. Disable autotype for an entry, if desired. 48 | - Auto-type custom attributes by hitting `Enter` on the desired attribute or 49 | by using the `{S:}` action code in your auto-type sequence. 50 | - Select any single field and have it typed into the active window. Notes fields 51 | can be viewed line-by-line and the selected line will be typed when 52 | selected. 53 | - `Enter` to open the URL in the default web browser from the View/Type 54 | menu. If you want to type the URL instead of opening, set `type_url = 55 | True` in config.ini. 56 | - *Edit* 57 | - Edit entry title, username, URL, attributes, and password (manually typed or auto-generate) 58 | - Edit notes using terminal or gui editor (set in config.ini, or uses $EDITOR) 59 | - Add and Delete entries 60 | - Rename, move, delete and add groups 61 | - *Configure* ([docs](configure.md)) 62 | - Prompts for and saves initial database and keyfile locations if config file 63 | isn't setup before first run. 64 | - Set multiple databases and keyfiles in the config file. 65 | - Hide selected groups from the default and 'View/Type Individual entries' views. 66 | - Keepmenu runs in the background after initial startup and will retain the 67 | entered passphrase for `pw_cache_period_min` minutes after the last activity. 68 | - Configure the characters and groups of characters used during password 69 | generation in the config file (see config.ini.example for instructions). 70 | Multiple character sets can be selected on the fly when using Rofi if the 71 | `-multi-select` option is passed via `dmenu_command`. 72 | - Optional Pinentry support for secure passphrase entry. 73 | - [Keepass field references][2] are supported. 74 | 75 | [1]: https://keepass.info/help/base/autotype.html#autoseq "Keepass 2.x codes" 76 | [2]: https://keepass.info/help/base/fieldrefs.html "Keepass field references" 77 | [3]: https://keepass.info/help/base/placeholders.html#otp "KeePass2 TOTP fields" 78 | [4]: https://github.com/tiuub/KeeOtp2 "KeeOtp2" 79 | [5]: https://github.com/KeeTrayTOTP/KeeTrayTOTP "KeeTrayTOTP" 80 | [6]: https://github.com/firecat53/keepmenu/issues/117#issuecomment-1182963273 "TOTP fields" 81 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1731979256, 6 | "narHash": "sha256-FNb8q3AnKbHNX+/Ftbnf3GIyADtIJs72gvYeX8CEHPU=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "80426b216e1285cfdacc85d83533e42c3a831040", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "repo": "nixpkgs", 15 | "type": "github" 16 | } 17 | }, 18 | "root": { 19 | "inputs": { 20 | "nixpkgs": "nixpkgs" 21 | } 22 | } 23 | }, 24 | "root": "root", 25 | "version": 7 26 | } 27 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Dmenu/Rofi/Wofi frontend for Keepass databases"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs"; 6 | }; 7 | 8 | outputs = { 9 | self, 10 | nixpkgs, 11 | }: let 12 | systems = ["x86_64-linux" "i686-linux" "aarch64-linux"]; 13 | forAllSystems = f: 14 | nixpkgs.lib.genAttrs systems (system: 15 | f { 16 | pkgs = nixpkgs.legacyPackages.${system}; 17 | }); 18 | in { 19 | devShells = forAllSystems ({pkgs}: { 20 | default = pkgs.mkShell { 21 | buildInputs = with pkgs; [ 22 | pandoc 23 | python3Packages.venvShellHook 24 | uv 25 | ]; 26 | venvDir = "./.venv"; 27 | C_INCLUDE_PATH = "${pkgs.linuxHeaders}/include"; 28 | HATCH_ENV_TYPE_VIRTUAL_UV_PATH = "${pkgs.uv}/bin/uv"; # use Nix uv instead of hatch downloaded binary 29 | PYTHONPATH = "$PYTHONPATH:$PWD"; 30 | shellHook = '' 31 | venvShellHook 32 | alias keepmenu="python -m keepmenu" 33 | ''; 34 | postVenvCreation = '' 35 | uv pip install hatch 36 | uv pip install -e . 37 | # Prevent venv uv from overriding nixpkgs uv 38 | [ -f $(pwd)/.venv/bin/uv ] && rm $(pwd)/.venv/bin/uv* 39 | ''; 40 | }; 41 | }); 42 | packages = forAllSystems ({pkgs}: { 43 | default = pkgs.python3Packages.buildPythonApplication { 44 | name = "keepmenu"; 45 | pname = "keepmenu"; 46 | format = "pyproject"; 47 | src = ./.; 48 | nativeBuildInputs = builtins.attrValues { 49 | inherit 50 | (pkgs) 51 | git 52 | ; 53 | inherit 54 | (pkgs.python3Packages) 55 | hatchling 56 | hatch-vcs 57 | ; 58 | }; 59 | propagatedBuildInputs = builtins.attrValues { 60 | inherit 61 | (pkgs.python3Packages) 62 | python 63 | pykeepass 64 | pynput 65 | ; 66 | }; 67 | meta = { 68 | description = "Dmenu/Rofi/Wofi frontend for Keepass databases"; 69 | homepage = "https://github.com/firecat53/keepmenu"; 70 | license = pkgs.lib.licenses.gpl3; 71 | maintainers = ["firecat53"]; 72 | platforms = systems; 73 | }; 74 | }; 75 | }); 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /keepmenu.1: -------------------------------------------------------------------------------- 1 | '\" t 2 | .\" Automatically generated by Pandoc 3.1.11.1 3 | .\" 4 | .TH "Keepmenu" "1" "05 July 2024" "Keepmenu 1.4.2" "User Manual" 5 | .SH NAME 6 | keepmenu \- Fully featured Dmenu/Rofi frontend for autotype and managing 7 | of Keepass databases. 8 | .SH SYNOPSIS 9 | \f[B]keepmenu\f[R] [\f[B]\[en]autotype\f[R] pattern] 10 | [\f[B]\[en]config\f[R] file] [\f[B]\[en]clipboard\f[R]] 11 | [\f[B]\[en]database\f[R] file] [\f[B]\[en]keyfile\f[R] file] 12 | [\f[B]\[en]totp\f[R]] 13 | .SH DESCRIPTION 14 | \f[B]Keepmenu\f[R] is a fast and minimal application to facilitate 15 | password entry and manage most aspects of Keepass .kdbx databases. 16 | It is inspired in part by Passhole, but is more dmenu and less command 17 | line focused. 18 | .SH OPTIONS 19 | \f[B]\-a\f[R], \f[B]\[en]autotype\f[R] Autotype sequence from 20 | https://keepass.info/help/base/autotype.html#autoseq . 21 | Overrides global default from config.ini for current database. 22 | .PP 23 | \f[B]\-c\f[R], \f[B]\[en]config\f[R] Path to config file 24 | .PP 25 | \f[B]\-C\f[R], \f[B]\[en]clipboard\f[R] Select to clipboard 26 | .PP 27 | \f[B]\-d\f[R], \f[B]\[en]database\f[R] Path to Keepass database 28 | .PP 29 | \f[B]\-k\f[R], \f[B]\[en]keyfile\f[R] Path to keyfile 30 | .PP 31 | \f[B]\-t\f[R], \f[B]\[en]totp\f[R] TOTP mode 32 | .SH EXAMPLES 33 | .IP 34 | .EX 35 | keepmenu 36 | keepmenu \-t 37 | keepmenu \-c /etc/keepmenu/config.ini 38 | keepmenu \-d \[ti]/docs/totp_passwords.kdbx \-a \[aq]{TOTP}{ENTER}\[aq] 39 | keepmenu \-d \[ti]/passwords.kdbx \-k \[ti]/passwords.keyfile \-a \[aq]{S:security question}{ENTER}\[aq] 40 | .EE 41 | .SH CONFIGURATION 42 | If you start keepmenu for the first time without a config file, it will 43 | prompt you for database and keyfile locations and save them in a default 44 | config file. 45 | .PP 46 | OR Copy config.ini.example to \[ti]/.config/keepmenu/config.ini and use 47 | it as a reference for additional options. 48 | .PP 49 | Alternatively you can specify the file path to your config.ini using the 50 | \-c/\[en]config flag. 51 | .SS config.ini options and defaults 52 | .PP 53 | .TS 54 | tab(@); 55 | lw(19.3n) lw(21.4n) lw(29.3n). 56 | T{ 57 | Section 58 | T}@T{ 59 | Key 60 | T}@T{ 61 | Default 62 | T} 63 | _ 64 | T{ 65 | \f[CR][dmenu]\f[R] 66 | T}@T{ 67 | \f[CR]dmenu_command\f[R] 68 | T}@T{ 69 | \f[CR]dmenu\f[R] 70 | T} 71 | T{ 72 | T}@T{ 73 | \f[CR]pinentry\f[R] 74 | T}@T{ 75 | None 76 | T} 77 | T{ 78 | T}@T{ 79 | \f[CR]title_path\f[R] 80 | T}@T{ 81 | \f[CR]True\f[R] 82 | T} 83 | T{ 84 | \f[CR][dmenu_passphrase]\f[R] 85 | T}@T{ 86 | \f[CR]obscure\f[R] 87 | T}@T{ 88 | \f[CR]False\f[R] 89 | T} 90 | T{ 91 | T}@T{ 92 | \f[CR]obscure_color\f[R] 93 | T}@T{ 94 | \f[CR]#222222\f[R] 95 | T} 96 | T{ 97 | \f[CR][database]\f[R] 98 | T}@T{ 99 | \f[CR]database_n\f[R] 100 | T}@T{ 101 | None 102 | T} 103 | T{ 104 | T}@T{ 105 | \f[CR]keyfile_n\f[R] 106 | T}@T{ 107 | None 108 | T} 109 | T{ 110 | T}@T{ 111 | \f[CR]password_n\f[R] 112 | T}@T{ 113 | None 114 | T} 115 | T{ 116 | T}@T{ 117 | \f[CR]password_cmd_n\f[R] 118 | T}@T{ 119 | None 120 | T} 121 | T{ 122 | T}@T{ 123 | \f[CR]autotype_default_n\f[R] 124 | T}@T{ 125 | None 126 | T} 127 | T{ 128 | T}@T{ 129 | \f[CR]pw_cache_period_min\f[R] 130 | T}@T{ 131 | \f[CR]360\f[R] 132 | T} 133 | T{ 134 | T}@T{ 135 | \f[CR]editor\f[R] 136 | T}@T{ 137 | \f[CR]vim\f[R] 138 | T} 139 | T{ 140 | T}@T{ 141 | \f[CR]terminal\f[R] 142 | T}@T{ 143 | \f[CR]xterm\f[R] 144 | T} 145 | T{ 146 | T}@T{ 147 | \f[CR]gui_editor\f[R] 148 | T}@T{ 149 | None 150 | T} 151 | T{ 152 | T}@T{ 153 | \f[CR]type_library\f[R] 154 | T}@T{ 155 | \f[CR]pynput\f[R] 156 | T} 157 | T{ 158 | T}@T{ 159 | \f[CR]hide_groups\f[R] 160 | T}@T{ 161 | None 162 | T} 163 | T{ 164 | T}@T{ 165 | \f[CR]autotype_default\f[R] 166 | T}@T{ 167 | \f[CR]{USERNAME}{TAB}{PASSWORD}{ENTER}\f[R] 168 | T} 169 | T{ 170 | T}@T{ 171 | \f[CR]type_url\f[R] 172 | T}@T{ 173 | \f[CR]False\f[R] 174 | T} 175 | T{ 176 | \f[CR][password_chars]\f[R] 177 | T}@T{ 178 | \f[CR]lower\f[R] 179 | T}@T{ 180 | \f[CR]abcdefghijklmnopqrstuvwxyz\f[R] 181 | T} 182 | T{ 183 | T}@T{ 184 | \f[CR]upper\f[R] 185 | T}@T{ 186 | \f[CR]ABCDEFGHIJKLMNOPQRSTUVWXYZ\f[R] 187 | T} 188 | T{ 189 | T}@T{ 190 | \f[CR]digits\f[R] 191 | T}@T{ 192 | \f[CR]0123456789\f[R] 193 | T} 194 | T{ 195 | T}@T{ 196 | \f[CR]punctuation\f[R] 197 | T}@T{ 198 | \f[CR]!\[dq]#$%%&\[aq]()*+,\-./:;<=>?\[at][\[rs]]\[ha]_\[ga]{│}\[ti]\f[R] 199 | T} 200 | T{ 201 | T}@T{ 202 | \f[CR]Custom Name(s)\f[R] 203 | T}@T{ 204 | \f[CR]Any string\f[R] 205 | T} 206 | T{ 207 | \f[CR][password_char_presets]\f[R] 208 | T}@T{ 209 | \f[CR]Letters+Digits+Punctuation\f[R] 210 | T}@T{ 211 | \f[CR]upper lower digits punctuation\f[R] 212 | T} 213 | T{ 214 | T}@T{ 215 | \f[CR]Letters+Digits\f[R] 216 | T}@T{ 217 | \f[CR]upper lower digits\f[R] 218 | T} 219 | T{ 220 | T}@T{ 221 | \f[CR]Letters\f[R] 222 | T}@T{ 223 | \f[CR]upper lower\f[R] 224 | T} 225 | T{ 226 | T}@T{ 227 | \f[CR]Digits\f[R] 228 | T}@T{ 229 | \f[CR]digits\f[R] 230 | T} 231 | T{ 232 | T}@T{ 233 | \f[CR]Custom Name(s)\f[R] 234 | T}@T{ 235 | \f[CR]Any combo of [password_chars] entries\f[R] 236 | T} 237 | .TE 238 | .SH FILES 239 | \[ti]/.config/keepmenu/config.ini 240 | .SH AUTHOR 241 | Scott Hansen \- \c 242 | .MT tech@firecat53.net 243 | .ME \c 244 | .SH COPYRIGHT 245 | GNU General Public License 3 246 | .SH SEE ALSO 247 | Full documentation available at https://github.com/firecat53/keepmenu 248 | -------------------------------------------------------------------------------- /keepmenu.1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Keepmenu 3 | section: 1 4 | header: User Manual 5 | footer: Keepmenu 1.4.2 6 | date: 05 July 2024 7 | --- 8 | 9 | # NAME 10 | 11 | keepmenu - Fully featured Dmenu/Rofi frontend for autotype and managing of Keepass databases. 12 | 13 | # SYNOPSIS 14 | 15 | **keepmenu** [**--autotype** pattern] [**--config** file] [**--clipboard**] [**--database** file] [**--keyfile** file] [**--totp**] 16 | 17 | # DESCRIPTION 18 | 19 | **Keepmenu** is a fast and minimal application to facilitate password entry and 20 | manage most aspects of Keepass .kdbx databases. It is inspired in part by 21 | Passhole, but is more dmenu and less command line focused. 22 | 23 | # OPTIONS 24 | 25 | **-a**, **--autotype** Autotype sequence from https://keepass.info/help/base/autotype.html#autoseq . Overrides global default from config.ini for current database. 26 | 27 | **-c**, **--config** Path to config file 28 | 29 | **-C**, **--clipboard** Select to clipboard 30 | 31 | **-d**, **--database** Path to Keepass database 32 | 33 | **-k**, **--keyfile** Path to keyfile 34 | 35 | **-t**, **--totp** TOTP mode 36 | 37 | # EXAMPLES 38 | 39 | keepmenu 40 | keepmenu -t 41 | keepmenu -c /etc/keepmenu/config.ini 42 | keepmenu -d ~/docs/totp_passwords.kdbx -a '{TOTP}{ENTER}' 43 | keepmenu -d ~/passwords.kdbx -k ~/passwords.keyfile -a '{S:security question}{ENTER}' 44 | 45 | # CONFIGURATION 46 | 47 | If you start keepmenu for the first time without a config file, it will prompt 48 | you for database and keyfile locations and save them in a default config file. 49 | 50 | OR Copy config.ini.example to ~/.config/keepmenu/config.ini and use it as a 51 | reference for additional options. 52 | 53 | Alternatively you can specify the file path to your config.ini using the -c/--config flag. 54 | 55 | ## config.ini options and defaults 56 | 57 | | Section | Key | Default | 58 | |---------------------------|------------------------------|-----------------------------------------| 59 | | `[dmenu]` | `dmenu_command` | `dmenu` | 60 | | | `pinentry` | None | 61 | | | `title_path` | `True` | 62 | | `[dmenu_passphrase]` | `obscure` | `False` | 63 | | | `obscure_color` | `#222222` | 64 | | `[database]` | `database_n` | None | 65 | | | `keyfile_n` | None | 66 | | | `password_n` | None | 67 | | | `password_cmd_n` | None | 68 | | | `autotype_default_n` | None | 69 | | | `pw_cache_period_min` | `360` | 70 | | | `editor` | `vim` | 71 | | | `terminal` | `xterm` | 72 | | | `gui_editor` | None | 73 | | | `type_library` | `pynput` | 74 | | | `hide_groups` | None | 75 | | | `autotype_default` | `{USERNAME}{TAB}{PASSWORD}{ENTER}` | 76 | | | `type_url` | `False` | 77 | | `[password_chars]` | `lower` | `abcdefghijklmnopqrstuvwxyz` | 78 | | | `upper` | `ABCDEFGHIJKLMNOPQRSTUVWXYZ` | 79 | | | `digits` | `0123456789` | 80 | | | `punctuation` | ``!"#$%%&'()*+,-./:;<=>?@[\]^_`{│}~`` | 81 | | | `Custom Name(s)` | `Any string` | 82 | | `[password_char_presets]` | `Letters+Digits+Punctuation` | `upper lower digits punctuation` | 83 | | | `Letters+Digits` | `upper lower digits` | 84 | | | `Letters` | `upper lower` | 85 | | | `Digits` | `digits` | 86 | | | `Custom Name(s)` | `Any combo of [password_chars] entries` | 87 | 88 | # FILES 89 | 90 | ~/.config/keepmenu/config.ini 91 | 92 | # AUTHOR 93 | 94 | Scott Hansen - 95 | 96 | # COPYRIGHT 97 | 98 | GNU General Public License 3 99 | 100 | # SEE ALSO 101 | 102 | Full documentation available at https://github.com/firecat53/keepmenu 103 | -------------------------------------------------------------------------------- /keepmenu/__init__.py: -------------------------------------------------------------------------------- 1 | """Set global variables. Read the config file. Create default config file if one 2 | doesn't exist. 3 | 4 | """ 5 | import configparser 6 | import locale 7 | import os 8 | import shlex 9 | from subprocess import run, DEVNULL 10 | import sys 11 | from os.path import exists, expanduser 12 | 13 | from keepmenu.menu import dmenu_err 14 | 15 | # Setup logging for debugging. Usage: logger.info(...) 16 | # import logging 17 | # logger = logging.getLogger(__name__) 18 | # logger.setLevel(logging.INFO) 19 | # file_handler = logging.FileHandler('keepmenu.log', mode='w') 20 | # formatter = logging.Formatter('%(message)s') 21 | # file_handler.setFormatter(formatter) 22 | # logger.addHandler(file_handler) 23 | 24 | AUTH_FILE = expanduser("~/.cache/.keepmenu-auth") 25 | CONF_FILE = expanduser("~/.config/keepmenu/config.ini") 26 | SECRET_VALID_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" 27 | 28 | ENV = os.environ.copy() 29 | ENC = locale.getpreferredencoding() 30 | CACHE_PERIOD_DEFAULT_MIN = 360 31 | CACHE_PERIOD_MIN = CACHE_PERIOD_DEFAULT_MIN 32 | SEQUENCE = "{USERNAME}{TAB}{PASSWORD}{ENTER}" 33 | MAX_LEN = 24 34 | CONF = configparser.ConfigParser() 35 | CLIPBOARD = False 36 | CLIPBOARD_CMD = "true" 37 | 38 | 39 | def reload_config(conf_file = None): # pylint: disable=too-many-statements,too-many-branches 40 | """Reload config file. Primarly for use with tests and the --config flag. 41 | 42 | Args: conf_file - os.path 43 | 44 | """ 45 | # pragma pylint: disable=global-statement,global-variable-not-assigned 46 | global CACHE_PERIOD_MIN, \ 47 | CACHE_PERIOD_DEFAULT_MIN, \ 48 | CLIPBOARD_CMD, \ 49 | CONF, \ 50 | MAX_LEN, \ 51 | ENV, \ 52 | ENC, \ 53 | SEQUENCE 54 | # pragma pylint: enable=global-variable-undefined,global-variable-not-assigned 55 | CONF = configparser.ConfigParser() 56 | conf_file = conf_file if conf_file is not None else CONF_FILE 57 | if not exists(conf_file): 58 | try: 59 | os.mkdir(os.path.dirname(conf_file)) 60 | except OSError: 61 | pass 62 | with open(conf_file, 'w', encoding=ENC) as cfile: 63 | CONF.add_section('dmenu') 64 | CONF.set('dmenu', 'dmenu_command', 'dmenu') 65 | CONF.add_section('dmenu_passphrase') 66 | CONF.set('dmenu_passphrase', 'obscure', 'True') 67 | CONF.set('dmenu_passphrase', 'obscure_color', '#222222') 68 | CONF.add_section('database') 69 | CONF.set('database', 'database_1', '') 70 | CONF.set('database', 'keyfile_1', '') 71 | CONF.set('database', 'pw_cache_period_min', str(CACHE_PERIOD_DEFAULT_MIN)) 72 | CONF.set('database', 'autotype_default', SEQUENCE) 73 | CONF.write(cfile) 74 | try: 75 | CONF.read(conf_file) 76 | except configparser.ParsingError as err: 77 | dmenu_err(f"Config file error: {err}") 78 | sys.exit() 79 | if CONF.has_option('dmenu', 'dmenu_command'): 80 | command = shlex.split(CONF.get('dmenu', 'dmenu_command')) 81 | else: 82 | CONF.set('dmenu', 'dmenu_command', 'dmenu') 83 | command = 'dmenu' 84 | if "-l" in command: 85 | MAX_LEN = int(command[command.index("-l") + 1]) 86 | if CONF.has_option("database", "pw_cache_period_min"): 87 | CACHE_PERIOD_MIN = int(CONF.get("database", "pw_cache_period_min")) 88 | else: 89 | CACHE_PERIOD_MIN = CACHE_PERIOD_DEFAULT_MIN 90 | if CONF.has_option('database', 'autotype_default'): 91 | SEQUENCE = CONF.get("database", "autotype_default") 92 | if CONF.has_option("database", "type_library"): 93 | for typ in ["xdotool", "ydotool", "wtype", "dotool"]: 94 | if CONF.get("database", "type_library") == typ: 95 | try: 96 | _ = run([typ, "--version"], check=False, stdout=DEVNULL, stderr=DEVNULL) 97 | except OSError: 98 | dmenu_err(f"{typ} not installed.\n" 99 | "Please install or remove that option from config.ini") 100 | sys.exit() 101 | if os.environ.get('WAYLAND_DISPLAY'): 102 | clips = ['wl-copy -o'] 103 | else: 104 | clips = ["xsel -b", "xclip -l 1 -selection clip"] 105 | for clip in clips: 106 | try: 107 | _ = run(shlex.split(clip), check=False, stdout=DEVNULL, stderr=DEVNULL, input="") 108 | CLIPBOARD_CMD = clip 109 | break 110 | except OSError: 111 | continue 112 | if CLIPBOARD_CMD == "true": 113 | dmenu_err(f"{' or '.join([shlex.split(i)[0] for i in clips])} needed for clipboard support") 114 | 115 | 116 | # vim: set et ts=4 sw=4 : 117 | -------------------------------------------------------------------------------- /keepmenu/__main__.py: -------------------------------------------------------------------------------- 1 | """Main entrypoint and CLI parsing 2 | 3 | """ 4 | import argparse 5 | import configparser 6 | from contextlib import closing 7 | from multiprocessing import Event, Process, Pipe 8 | from multiprocessing.managers import BaseManager 9 | import os 10 | from os.path import exists, expanduser 11 | import random 12 | import socket 13 | import string 14 | from subprocess import call 15 | import sys 16 | 17 | import keepmenu 18 | from keepmenu.keepmenu import DmenuRunner 19 | 20 | 21 | def find_free_port(): 22 | """Find random free port to use for BaseManager server 23 | 24 | Returns: int Port 25 | 26 | """ 27 | with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: 28 | sock.bind(('127.0.0.1', 0)) # pylint:disable=no-member 29 | return sock.getsockname()[1] # pylint:disable=no-member 30 | 31 | def port_in_use(port): 32 | """Return Boolean 33 | 34 | """ 35 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 36 | return s.connect_ex(('127.0.0.1', port)) == 0 37 | 38 | def get_auth(): 39 | """Generate and save port and authkey to ~/.cache/.keepmenu-auth 40 | 41 | Returns: int port, bytestring authkey 42 | 43 | """ 44 | auth = configparser.ConfigParser() 45 | if not exists(keepmenu.AUTH_FILE): 46 | fd_ = os.open(keepmenu.AUTH_FILE, os.O_WRONLY | os.O_CREAT, 0o600) 47 | with open(fd_, 'w', encoding=keepmenu.ENC) as a_file: 48 | auth.set('DEFAULT', 'port', str(find_free_port())) 49 | auth.set('DEFAULT', 'authkey', random_str()) 50 | auth.write(a_file) 51 | try: 52 | auth.read(keepmenu.AUTH_FILE) 53 | port = auth.get('DEFAULT', 'port') 54 | authkey = auth.get('DEFAULT', 'authkey').encode() 55 | except (configparser.NoOptionError, configparser.MissingSectionHeaderError): 56 | os.remove(keepmenu.AUTH_FILE) 57 | print("Cache file was corrupted. Stopping all instances. Please try again") 58 | call(["pkill", "keepmenu"]) # Kill all prior instances as well 59 | return None, None 60 | return int(port), authkey 61 | 62 | 63 | def random_str(): 64 | """Generate random auth string for BaseManager 65 | 66 | Returns: string 67 | 68 | """ 69 | letters = string.ascii_lowercase 70 | return ''.join(random.choice(letters) for i in range(15)) 71 | 72 | 73 | def client(port, auth): 74 | """Define client connection to server BaseManager 75 | 76 | Returns: BaseManager object 77 | """ 78 | mgr = BaseManager(address=('', port), authkey=auth) 79 | mgr.register('set_event') 80 | mgr.register('get_pipe') 81 | mgr.register('read_args_from_pipe') 82 | mgr.register('totp_mode') 83 | mgr.connect() 84 | 85 | return mgr 86 | 87 | 88 | class Server(Process): # pylint: disable=too-many-instance-attributes 89 | """Run BaseManager server to listen for dmenu calling events 90 | 91 | """ 92 | def __init__(self): 93 | Process.__init__(self) 94 | self.port, self.authkey = get_auth() 95 | self.start_flag = Event() 96 | self.kill_flag = Event() 97 | self.cache_time_expired = Event() 98 | self.args_flag = Event() 99 | self.totp_flag = Event() 100 | self.start_flag.set() 101 | self.args = None 102 | self._parent_conn, self._child_conn = Pipe(duplex=False) 103 | 104 | def run(self): 105 | _ = self.server() 106 | try: 107 | self.kill_flag.wait() 108 | except KeyboardInterrupt: 109 | self.kill_flag.set() 110 | 111 | def _get_pipe(self): 112 | return self._child_conn 113 | 114 | def get_args(self): 115 | """ Reads aruments sent by the client to the server 116 | 117 | """ 118 | return self._parent_conn.recv() 119 | 120 | def server(self): 121 | """Set up BaseManager server 122 | 123 | """ 124 | mgr = BaseManager(address=('127.0.0.1', self.port), 125 | authkey=self.authkey) 126 | mgr.register('set_event', callable=self.start_flag.set) 127 | mgr.register('get_pipe', callable=self._get_pipe) 128 | mgr.register('read_args_from_pipe', callable=self.args_flag.set) 129 | mgr.register('totp_mode', callable=self.totp_flag.set) 130 | mgr.start() # pylint: disable=consider-using-with 131 | return mgr 132 | 133 | 134 | def run(**kwargs): 135 | """Start the background Manager and Dmenu runner processes. 136 | 137 | """ 138 | server = Server() 139 | if kwargs.get('totp'): 140 | server.totp_flag.set() 141 | dmenu = DmenuRunner(server, **kwargs) 142 | dmenu.daemon = True 143 | server.start() 144 | dmenu.start() 145 | try: 146 | server.join() 147 | except KeyboardInterrupt: 148 | sys.exit() 149 | finally: 150 | if exists(expanduser(keepmenu.AUTH_FILE)): 151 | os.remove(expanduser(keepmenu.AUTH_FILE)) 152 | 153 | 154 | def main(): 155 | """Main script entrypoint 156 | 157 | """ 158 | parser = argparse.ArgumentParser( 159 | description="Dmenu (or compatible launcher) frontend for Keepass databases") 160 | 161 | parser.add_argument( 162 | "-a", 163 | "--autotype", 164 | type=str, 165 | required=False, 166 | help="Override autotype sequence in config.ini", 167 | ) 168 | 169 | parser.add_argument( 170 | "-c", 171 | "--config", 172 | type=str, 173 | required=False, 174 | help="File path to a config file", 175 | ) 176 | 177 | parser.add_argument( 178 | "-C", 179 | "--clipboard", 180 | action="store_true", 181 | default=False, 182 | required=False, 183 | help="Copy values to clipboard instead of typing.", 184 | ) 185 | 186 | parser.add_argument( 187 | "-d", 188 | "--database", 189 | type=str, 190 | required=False, 191 | help="File path to a database to open, skipping the database selection menu", 192 | ) 193 | 194 | parser.add_argument( 195 | "-k", 196 | "--keyfile", 197 | type=str, 198 | required=False, 199 | help="File path of the keyfile needed to open the database specified by --database/-d", 200 | ) 201 | 202 | parser.add_argument( 203 | "-t", 204 | "--totp", 205 | action='store_true', 206 | required=False, 207 | help="TOTP mode", 208 | ) 209 | 210 | args = vars(parser.parse_args()) 211 | 212 | port, auth = get_auth() 213 | if port_in_use(port) is False: 214 | run(**args) 215 | try: 216 | manager = client(port, auth) 217 | conn = manager.get_pipe() # pylint: disable=no-member 218 | if args.get('totp'): 219 | manager.totp_mode() # pylint: disable=no-member 220 | if any(args.values()): 221 | conn.send(args) 222 | manager.read_args_from_pipe() # pylint: disable=no-member 223 | manager.set_event() # pylint: disable=no-member 224 | except ConnectionRefusedError: 225 | # Don't print the ConnectionRefusedError if any other exceptions are 226 | # raised. 227 | pass 228 | 229 | 230 | if __name__ == '__main__': 231 | main() 232 | 233 | # vim: set et ts=4 sw=4 : 234 | -------------------------------------------------------------------------------- /keepmenu/edit.py: -------------------------------------------------------------------------------- 1 | """Methods for editing entries and groups 2 | 3 | """ 4 | from datetime import datetime 5 | import os 6 | import random 7 | from secrets import choice 8 | import shlex 9 | import string 10 | from subprocess import call 11 | import tempfile 12 | from urllib import parse 13 | 14 | import keepmenu 15 | from keepmenu.menu import dmenu_select, dmenu_err 16 | from keepmenu.totp import gen_otp, get_otp_url, TOTP_FIELDS 17 | from keepmenu.type import type_text 18 | 19 | 20 | def add_entry(kpo): 21 | """Add Keepass entry 22 | 23 | Args: kpo - Keepass object 24 | Returns: False if not added 25 | Keepass Entry object on success 26 | 27 | """ 28 | group = select_group(kpo) 29 | if group is False: 30 | return False 31 | entry = kpo.add_entry(destination_group=group, title="", username="", password="") 32 | edit = True 33 | while edit is True: 34 | edit = edit_entry(kpo, entry) 35 | return entry 36 | 37 | 38 | def delete_entry(kpo, kp_entry): 39 | """Delete an entry 40 | 41 | Args: kpo - Keepass object 42 | kp_entry - keepass entry 43 | Returns: True if no delete 44 | 'del' if delete 45 | 46 | """ 47 | inp = "NO\nYes - confirm delete\n" 48 | delete = dmenu_select(2, "Confirm delete", inp=inp) 49 | if delete != "Yes - confirm delete": 50 | return True 51 | kpo.delete_entry(kp_entry) 52 | kpo.save() 53 | return 'del' 54 | 55 | 56 | def edit_entry(kpo, kp_entry): 57 | # pylint: disable=too-many-return-statements,too-many-branches,too-many-statements 58 | """Edit title, username, password, url and autotype sequence for an entry. 59 | 60 | Args: kpo - Keepass object 61 | kp_entry - selected Entry object 62 | 63 | Returns: True to continue editing 64 | False if done 65 | "del" if entry was deleted 66 | 67 | """ 68 | fields = [str(f"Title: {kp_entry.title}"), 69 | str(f"Path: {'/'.join(kp_entry.path[:-1])}"), 70 | str(f"Username: {kp_entry.username}"), 71 | str("Password: **********") if kp_entry.password else "Password: None", 72 | str("TOTP: ******") if get_otp_url(kp_entry) else "TOTP: None", 73 | str(f"Url: {kp_entry.url}"), 74 | "Notes: " if kp_entry.notes else "Notes: None", 75 | str(f"Expiry time: {kp_entry.expiry_time.strftime('%Y-%m-%d %H:%M')}") 76 | if kp_entry.expires is True else "Expiry time: None"] 77 | 78 | attrs = kp_entry.custom_properties 79 | for attr in attrs: 80 | if attr not in TOTP_FIELDS: 81 | val = attrs.get(attr) or "" 82 | value = val or "None" if len(val.split('\n')) <= 1 else "" 83 | fields.append(f'{attr}: {value}') 84 | 85 | fields.append("Add Attribute: ") 86 | fields.append("Delete Entry: ") 87 | 88 | if hasattr(kp_entry, 'autotype_sequence') and hasattr(kp_entry, 'autotype_enabled'): 89 | fields[5:5] = [str(f"Autotype Sequence: {kp_entry.autotype_sequence}"), 90 | str(f"Autotype Enabled: {kp_entry.autotype_enabled}")] 91 | inp = "\n".join(fields) 92 | sel = dmenu_select(len(fields), inp=inp) 93 | 94 | # Needs to be above so that underscores are not added to the key 95 | for attr in attrs: 96 | if sel in (f'{attr}: {attrs.get(attr) or "None"}', f'{attr}: '): 97 | edit_additional_attributes(kp_entry, attr, attrs.get(attr) or "") 98 | return True 99 | 100 | try: 101 | field, sel = sel.split(": ", 1) 102 | except (ValueError, TypeError): 103 | return False 104 | field = field.lower().replace(" ", "_") 105 | if field == 'password': 106 | sel = kp_entry.password 107 | edit = f"{sel}\n" if sel is not None else "\n" 108 | if field == 'delete_entry': 109 | return delete_entry(kpo, kp_entry) 110 | if field == 'path': 111 | group = select_group(kpo) 112 | if not group: 113 | return True 114 | kpo.move_entry(kp_entry, group) 115 | return True 116 | pw_choice = "" 117 | if field == 'password': 118 | inputs = [ 119 | "Generate password", 120 | "Manually enter password", 121 | ] 122 | if kp_entry.password: 123 | inputs.append("Type existing password") 124 | pw_choice = dmenu_select(len(inputs), "Password Options", inp="\n".join(inputs)) 125 | if pw_choice == "Manually enter password": 126 | pass 127 | elif pw_choice == "Type existing password": 128 | type_text(kp_entry.password) 129 | return False 130 | elif not pw_choice: 131 | return True 132 | else: 133 | pw_choice = '' 134 | length = dmenu_select(1, "Password Length?", inp="20\n") 135 | if not length: 136 | return True 137 | try: 138 | length = int(length) 139 | except ValueError: 140 | length = 20 141 | chars = get_password_chars() 142 | if chars is False: 143 | return True 144 | sel = gen_passwd(chars, length) 145 | if sel is False: 146 | dmenu_err("Number of char groups desired is more than requested pw length") 147 | return True 148 | if field == 'totp': 149 | edit_totp(kp_entry) 150 | return True 151 | 152 | if field == "add_attribute": 153 | add_additional_attribute(kp_entry) 154 | return True 155 | 156 | if field == 'autotype_enabled': 157 | inp = "True\nFalse\n" 158 | at_enab = dmenu_select(2, "Autotype Enabled? True/False", inp=inp) 159 | if not at_enab: 160 | return True 161 | sel = not at_enab == 'False' 162 | 163 | if field == 'expiry_time': 164 | edit_expiry(kp_entry) 165 | return True 166 | 167 | if (field not in ('password', 'notes', 'path', 'autotype_enabled')) or pw_choice: 168 | sel = dmenu_select(1, f"{field.capitalize()}", inp=edit) 169 | if not sel: 170 | return True 171 | if pw_choice: 172 | sel_check = dmenu_select(1, f"{field.capitalize()}", inp=edit) 173 | if not sel_check or sel_check != sel: 174 | dmenu_err("Passwords do not match. No changes made.") 175 | return True 176 | elif field == 'notes': 177 | sel = edit_notes(kp_entry.notes) 178 | 179 | setattr(kp_entry, field, sel) 180 | return True 181 | 182 | 183 | def edit_expiry(kp_entry): 184 | """Edit expiration date 185 | 186 | Args: kp_entry - selected Entry object 187 | 188 | Input can be date and time, date or time 189 | 190 | """ 191 | sel = dmenu_select(1, 192 | "Expiration Date (yyyy-mm-dd hh:mm OR " 193 | "yyyy-mm-dd OR HH:MM OR " 194 | "'None' to unset)", 195 | inp=kp_entry.expiry_time.strftime("%Y-%m-%d %H:%M") if 196 | kp_entry.expires is True else "") 197 | if not sel: 198 | return True 199 | if sel.lower() == "none": 200 | setattr(kp_entry, "expires", False) 201 | return True 202 | dtime = exp_date(sel) 203 | if dtime: 204 | setattr(kp_entry, "expires", True) 205 | setattr(kp_entry, "expiry_time", dtime) 206 | return True 207 | 208 | 209 | def exp_date(dtime): 210 | """Convert string to datetime 211 | 212 | """ 213 | formats = ["%Y-%m-%d %H:%M", "%Y-%m-%d", "%H:%M"] 214 | for fmt in formats: 215 | try: 216 | return datetime.strptime(dtime, fmt) 217 | except ValueError: 218 | continue 219 | dmenu_err("Invalid format. No changes made") 220 | return None 221 | 222 | 223 | def edit_totp(kp_entry): # pylint: disable=too-many-statements,too-many-branches 224 | """Edit TOTP generation information 225 | 226 | Args: kp_entry - selected Entry object 227 | 228 | """ 229 | otp_url = get_otp_url(kp_entry) 230 | 231 | if otp_url is not None: 232 | inputs = [ 233 | "Enter secret key", 234 | "Type TOTP", 235 | ] 236 | otp_choice = dmenu_select(len(inputs), "TOTP", inp="\n".join(inputs)) 237 | else: 238 | otp_choice = "Enter secret key" 239 | 240 | if otp_choice == "Type TOTP": 241 | type_text(gen_otp(otp_url)) 242 | elif otp_choice == "Enter secret key": 243 | inputs = [] 244 | if otp_url: 245 | parsed_otp_url = parse.urlparse(otp_url) 246 | query_string = parse.parse_qs(parsed_otp_url.query) 247 | inputs = [query_string["secret"][0]] 248 | secret_key = dmenu_select(1, "Secret Key?", inp="\n".join(inputs)) 249 | 250 | if not secret_key: 251 | return 252 | 253 | for char in secret_key: 254 | if char.upper() not in keepmenu.SECRET_VALID_CHARS: 255 | dmenu_err("Invaild character in secret key, " 256 | f"valid characters are {keepmenu.SECRET_VALID_CHARS}") 257 | return 258 | 259 | inputs = [ 260 | "Defaut RFC 6238 token settings", 261 | "Steam token settings", 262 | "Use custom settings" 263 | ] 264 | 265 | otp_settings_choice = dmenu_select(len(inputs), "Settings", inp="\n".join(inputs)) 266 | 267 | if otp_settings_choice == "Defaut RFC 6238 token settings": 268 | algorithm_choice = "sha1" 269 | time_step_choice = 30 270 | code_size_choice = 6 271 | elif otp_settings_choice == "Steam token settings": 272 | algorithm_choice = "sha1" 273 | time_step_choice = 30 274 | code_size_choice = 5 275 | elif otp_settings_choice == "Use custom settings": 276 | inputs = ["SHA-1", "SHA-256", "SHA-512"] 277 | algorithm_choice = dmenu_select(len(inputs), "Algorithm", inp="\n".join(inputs)) 278 | if not algorithm_choice: 279 | return 280 | algorithm_choice = algorithm_choice.replace("-", "").lower() 281 | 282 | time_step_choice = dmenu_select(1, "Time Step (sec)", inp="30\n") 283 | if not time_step_choice: 284 | return 285 | try: 286 | time_step_choice = int(time_step_choice) 287 | except ValueError: 288 | time_step_choice = 30 289 | 290 | code_size_choice = dmenu_select(1, "Code Size", inp="6\n") 291 | if not code_size_choice: 292 | return 293 | try: 294 | code_size_choice = int(time_step_choice) 295 | except ValueError: 296 | code_size_choice = 6 297 | 298 | otp_url = (f"otpauth://totp/Main:none?secret={secret_key}&period={time_step_choice}" 299 | f"&digits={code_size_choice}&issuer=Main") 300 | if algorithm_choice != "sha1": 301 | otp_url += "&algorithm=" + algorithm_choice 302 | if otp_settings_choice == "Steam token settings": 303 | otp_url += "&encoder=steam" 304 | 305 | if hasattr(kp_entry, "otp"): 306 | setattr(kp_entry, "otp", otp_url) 307 | else: 308 | kp_entry.set_custom_property("otp", otp_url) 309 | 310 | 311 | def add_additional_attribute(kp_entry): 312 | """Add additional attribute 313 | 314 | Args: kp_entry - Entry object 315 | 316 | """ 317 | key = dmenu_select(0, "Attribute Name: ", inp="") 318 | if not key: 319 | return 320 | edit_additional_attributes(kp_entry, key, "") 321 | 322 | 323 | def edit_additional_attributes(kp_entry, key, value): 324 | """Edit/Delete additional attributes 325 | 326 | Args: kp_entry - Entry object 327 | key - attr name (string) 328 | value - attr value (string) 329 | 330 | """ 331 | options = ["Multi-line Edit", "Delete"] 332 | if len(value.split('\n')) == 1: 333 | options.insert(0, value) 334 | sel = dmenu_select(len(options), f"{key}:", inp="\n".join(options)) 335 | if sel == "Delete": 336 | kp_entry.delete_custom_property(key) 337 | return 338 | if sel == "Multi-line Edit": 339 | sel = edit_notes(value) 340 | if sel: 341 | kp_entry.set_custom_property(key, sel) 342 | 343 | 344 | def edit_notes(note): 345 | """Use $EDITOR (or 'vim' if not set) to edit the notes entry 346 | 347 | In configuration file: 348 | Set 'gui_editor' for things like emacs, gvim, leafpad 349 | Set 'editor' for vim, emacs -nw, nano unless $EDITOR is defined 350 | Set 'terminal' if using a non-gui editor 351 | 352 | Args: note - string 353 | Returns: note - string 354 | 355 | """ 356 | if keepmenu.CONF.has_option("database", "gui_editor"): 357 | editor = keepmenu.CONF.get("database", "gui_editor") 358 | editor = shlex.split(editor) 359 | else: 360 | if keepmenu.CONF.has_option("database", "editor"): 361 | editor = keepmenu.CONF.get("database", "editor") 362 | else: 363 | editor = os.environ.get('EDITOR', 'vim') 364 | if keepmenu.CONF.has_option("database", "terminal"): 365 | terminal = keepmenu.CONF.get("database", "terminal") 366 | else: 367 | terminal = "xterm" 368 | terminal = shlex.split(terminal) 369 | editor = shlex.split(editor) 370 | editor = terminal + ["-e"] + editor 371 | note = b'' if note is None else note.encode(keepmenu.ENC) 372 | with tempfile.NamedTemporaryFile(suffix=".tmp") as fname: 373 | fname.write(note) 374 | fname.flush() 375 | editor.append(fname.name) 376 | try: 377 | call(editor) 378 | except FileNotFoundError: 379 | dmenu_err("Terminal not found. Please update config.ini.") 380 | note = '' if not note else note.decode(keepmenu.ENC) 381 | return note 382 | fname.seek(0) 383 | note = fname.read() 384 | note = '' if not note else note.decode(keepmenu.ENC) 385 | return note.strip() 386 | 387 | 388 | def gen_passwd(chars, length=20): 389 | """Generate password (min = # of distinct character sets picked) 390 | 391 | Args: chars - Dict {preset_name_1: {char_set_1: string, char_set_2: string}, 392 | preset_name_2: ....} 393 | length - int (default 20) 394 | 395 | Returns: password - string OR False 396 | 397 | """ 398 | sets = set() 399 | if chars: 400 | sets = set(j for i in chars.values() for j in i.values()) 401 | if length < len(sets) or not chars: 402 | return False 403 | alphabet = "".join(set("".join(j for j in i.values()) for i in chars.values())) 404 | # Ensure minimum of one char from each character set 405 | password = "".join(choice(k) for k in sets) 406 | password += "".join(choice(alphabet) for i in range(length - len(sets))) 407 | tpw = list(password) 408 | random.shuffle(tpw) 409 | return "".join(tpw) 410 | 411 | 412 | def get_password_chars(): 413 | """Get characters to use for password generation from defaults, config file 414 | and user input. 415 | 416 | Returns: Dict {preset_name_1: {char_set_1: string, char_set_2: string}, 417 | preset_name_2: ....} 418 | """ 419 | chars = {"upper": string.ascii_uppercase, 420 | "lower": string.ascii_lowercase, 421 | "digits": string.digits, 422 | "punctuation": string.punctuation} 423 | presets = {} 424 | presets["Letters+Digits+Punctuation"] = chars 425 | presets["Letters+Digits"] = {k: chars[k] for k in ("upper", "lower", "digits")} 426 | presets["Letters"] = {k: chars[k] for k in ("upper", "lower")} 427 | presets["Digits"] = {k: chars[k] for k in ("digits",)} 428 | if keepmenu.CONF.has_section('password_chars'): 429 | pw_chars = dict(keepmenu.CONF.items('password_chars')) 430 | chars.update(pw_chars) 431 | for key, val in pw_chars.items(): 432 | presets[key.title()] = {k: chars[k] for k in (key,)} 433 | if keepmenu.CONF.has_section('password_char_presets'): 434 | if keepmenu.CONF.options('password_char_presets'): 435 | presets = {} 436 | for name, val in keepmenu.CONF.items('password_char_presets'): 437 | try: 438 | presets[name.title()] = {k: chars[k] for k in shlex.split(val)} 439 | except KeyError: 440 | print(f"Error: Unknown value in preset {name}. Ignoring.") 441 | continue 442 | inp = "\n".join(presets) 443 | char_sel = dmenu_select(len(presets), 444 | "Pick character set(s) to use", inp=inp) 445 | # This dictionary return also handles launcher multiple select 446 | return {k: presets[k] for k in char_sel.split('\n')} if char_sel else False 447 | 448 | 449 | def select_group(kpo, prompt="Groups"): 450 | """Select which group for an entry 451 | 452 | Args: kpo - Keepass object 453 | options - list of menu options for groups 454 | 455 | Returns: False for no entry 456 | group - string 457 | 458 | """ 459 | groups = kpo.groups 460 | num_align = len(str(len(groups))) 461 | pattern = str("{:>{na}} - {}") 462 | inp = str("\n").join([pattern.format(j, "/".join(i.path), na=num_align) 463 | for j, i in enumerate(groups)]) 464 | sel = dmenu_select(min(keepmenu.MAX_LEN, len(groups)), prompt, inp=inp) 465 | if not sel: 466 | return False 467 | try: 468 | return groups[int(sel.split('-', 1)[0])] 469 | except (ValueError, TypeError): 470 | return False 471 | 472 | 473 | def manage_groups(kpo): 474 | """Rename, create, move or delete groups 475 | 476 | Args: kpo - Keepass object 477 | Returns: Group object or False 478 | 479 | """ 480 | edit = True 481 | options = ['Create', 482 | 'Move', 483 | 'Rename', 484 | 'Delete'] 485 | group = False 486 | while edit is True: 487 | inp = "\n".join(i for i in options) + "\n\n" + \ 488 | "\n".join("/".join(i.path) for i in kpo.groups) 489 | sel = dmenu_select(len(options) + min(keepmenu.MAX_LEN, len(kpo.groups)) + 1, 490 | "Groups", 491 | inp=inp) 492 | if not sel: 493 | edit = False 494 | elif sel == 'Create': 495 | group = create_group(kpo) 496 | elif sel == 'Move': 497 | group = move_group(kpo) 498 | elif sel == 'Rename': 499 | group = rename_group(kpo) 500 | elif sel == 'Delete': 501 | group = delete_group(kpo) 502 | else: 503 | edit = False 504 | return group 505 | 506 | 507 | def create_group(kpo): 508 | """Create new group 509 | 510 | Args: kpo - Keepass object 511 | Returns: Group object or False 512 | 513 | """ 514 | parentgroup = select_group(kpo, prompt="Select parent group") 515 | if not parentgroup: 516 | return False 517 | name = dmenu_select(1, "Group name") 518 | if not name: 519 | return False 520 | group = kpo.add_group(parentgroup, name) 521 | kpo.save() 522 | return group 523 | 524 | 525 | def delete_group(kpo): 526 | """Delete a group 527 | 528 | Args: kpo - Keepass object 529 | Returns: Group object or False 530 | 531 | """ 532 | group = select_group(kpo, prompt="Delete Group:") 533 | if not group: 534 | return False 535 | inp = "NO\nYes - confirm delete\n" 536 | delete = dmenu_select(2, "Confirm delete", inp=inp) 537 | if delete != "Yes - confirm delete": 538 | return True 539 | kpo.delete_group(group) 540 | kpo.save() 541 | return group 542 | 543 | 544 | def move_group(kpo): 545 | """Move group 546 | 547 | Args: kpo - Keepass object 548 | Returns: Group object or False 549 | 550 | """ 551 | group = select_group(kpo, prompt="Select group to move") 552 | if not group: 553 | return False 554 | destgroup = select_group(kpo, prompt="Select destination group") 555 | if not destgroup: 556 | return False 557 | group = kpo.move_group(group, destgroup) 558 | kpo.save() 559 | return group 560 | 561 | 562 | def rename_group(kpo): 563 | """Rename group 564 | 565 | Args: kpo - Keepass object 566 | Returns: Group object or False 567 | 568 | """ 569 | group = select_group(kpo, prompt="Select group to rename") 570 | if not group: 571 | return False 572 | name = dmenu_select(1, "New group name", inp=group.name) 573 | if not name: 574 | return False 575 | group.name = name 576 | kpo.save() 577 | return group 578 | 579 | 580 | def create_db(db_name="", keyfile="", password=""): 581 | """Create new keepass database 582 | 583 | Args: db_name - complete path to database file 584 | keyfile - complete path to keyfile 585 | password 586 | Returns: False if not added 587 | New Keepass object if successful 588 | 589 | """ 590 | if not db_name: 591 | db_name = dmenu_select(1, "Database Name (including path)") 592 | if not db_name: 593 | return False 594 | if not keyfile: 595 | keyfile = dmenu_select(1, "Keyfile (optional, including path)") 596 | if not password: 597 | password = keepmenu.keepmenu.get_passphrase() 598 | password_check = keepmenu.keepmenu.get_passphrase(check=True) 599 | if password != password_check: 600 | dmenu_err("Passwords do not match, database not created") 601 | return False 602 | from pykeepass import create_database # pylint: disable=import-outside-toplevel 603 | kpo = create_database(filename=os.path.expanduser(db_name), 604 | password=password, 605 | keyfile=os.path.expanduser(keyfile)) 606 | return kpo 607 | -------------------------------------------------------------------------------- /keepmenu/keepmenu.py: -------------------------------------------------------------------------------- 1 | """Read and copy Keepass database entries using dmenu style launchers 2 | 3 | """ 4 | from copy import copy 5 | from dataclasses import dataclass 6 | from datetime import datetime, timedelta 7 | import errno 8 | import functools 9 | from multiprocessing import Process 10 | from os.path import expanduser, isfile, realpath 11 | import shlex 12 | import subprocess 13 | import sys 14 | from threading import Timer 15 | 16 | import construct 17 | import keepmenu 18 | from keepmenu.edit import add_entry, create_db, edit_entry, manage_groups 19 | from keepmenu.menu import dmenu_err, dmenu_select 20 | from keepmenu.type import type_entry, type_text 21 | from keepmenu.view import view_all_entries, view_entry 22 | from keepmenu.totp import gen_otp, get_otp_url 23 | 24 | 25 | @dataclass 26 | class DataBase: 27 | """Define a database class for clearer reference to variables 28 | 29 | dbase - string, filename 30 | kfile - string, filename 31 | pword - string, password 32 | atype - string, autotype sequence 33 | totp - bool, TOTP mode 34 | kpo - PyKeePass object 35 | is_active - bool, is this the currently active database 36 | 37 | """ 38 | dbase: str = "" 39 | kfile: str = "" 40 | pword: str = None 41 | atype: str = None 42 | totp: bool = False 43 | is_active: bool = False 44 | kpo: str = None # Placeholder for pykeepass object 45 | 46 | def __post_init__(self): 47 | self.dbase = realpath(expanduser(self.dbase)) if self.dbase else "" 48 | self.kfile = realpath(expanduser(self.kfile)) if self.kfile else "" 49 | 50 | 51 | def get_databases(): 52 | """Read databases from config 53 | 54 | Returns: [DataBase obj, DataBase obj2,...] 55 | If not specified in the config, the value will be None 56 | If database name is None, an error has occurred 57 | 58 | """ 59 | dargs = keepmenu.CONF.items('database') 60 | args_dict = dict(dargs) 61 | dbases = [i for i in args_dict if i.startswith('database')] 62 | dbs = [] 63 | for dbase in dbases: 64 | dbn = args_dict[dbase] 65 | idx = dbase.rsplit('_', 1)[-1] 66 | try: 67 | keyfile = args_dict[f'keyfile_{idx}'] 68 | except KeyError: 69 | keyfile = None 70 | try: 71 | passw = args_dict[f'password_{idx}'] 72 | except KeyError: 73 | passw = None 74 | try: 75 | autotype = args_dict[f'autotype_default_{idx}'] 76 | except KeyError: 77 | autotype = None 78 | try: 79 | cmd = expanduser(args_dict[f'password_cmd_{idx}']) 80 | res = subprocess.run(shlex.split(cmd), 81 | capture_output=True, 82 | check=False, 83 | encoding=keepmenu.ENC) 84 | if res.stderr: 85 | dmenu_err(f"Password command error: {res.stderr}") 86 | sys.exit() 87 | else: 88 | passw = res.stdout.rstrip('\n') if res.stdout else passw 89 | except KeyError: 90 | pass 91 | if dbn: 92 | dbo = DataBase(dbase=dbn, kfile=keyfile, pword=passw, atype=autotype) 93 | dbs.append(dbo) 94 | 95 | return dbs 96 | 97 | 98 | def get_database(open_databases=None, **kwargs): 99 | # pylint: disable=too-many-statements,too-many-branches 100 | """Read databases/keyfile/autotype from config, CLI, or ask for user input. 101 | 102 | Args: open_databases - list [DataBase1, DataBase2,...] 103 | kwargs - possibly 'database', 'keyfile' 104 | Returns: DataBase obj or None on error selecting database or password 105 | open_databases - list [DataBase1, DataBase2,...] 106 | (open_databases is managed in get_database but stored in 107 | DmenuRunner for persistence instead of using a global var) 108 | 109 | """ 110 | dbs_cfg = get_databases() 111 | dbs_cfg_n = [i.dbase for i in dbs_cfg] 112 | open_databases = open_databases or {} 113 | clidb = DataBase(dbase=kwargs.get('database'), 114 | kfile=kwargs.get('keyfile'), 115 | atype=kwargs.get('autotype'), 116 | totp=kwargs.get('totp', False)) 117 | if not dbs_cfg and not clidb.dbase and not open_databases: 118 | # First run database opening 119 | res = get_initial_db() 120 | if res is True: 121 | db_, open_databases = get_database() 122 | dbs = [db_] 123 | else: 124 | return None, open_databases 125 | elif clidb.dbase: 126 | # Prefer db, autotype, totp passed via cli 127 | db_ = [i for i in open_databases.values() if i.dbase == clidb.dbase] 128 | if db_: 129 | dbs = [copy(db_[0])] 130 | if clidb.atype: 131 | dbs[0].atype = clidb.atype 132 | if clidb.totp: 133 | dbs[0].totp = clidb.totp 134 | else: 135 | dbs = [copy(clidb)] 136 | # Use existing keyfile if available 137 | if not dbs[0].kfile and dbs[0].dbase in dbs_cfg_n: 138 | dbs[0].kfile = dbs_cfg[dbs_cfg_n.index(dbs[0].dbase)].kfile 139 | # Use existing password if available 140 | if dbs[0].pword is None and dbs[0].dbase in dbs_cfg_n: 141 | dbs[0].pword = dbs_cfg[dbs_cfg_n.index(dbs[0].dbase)].pword 142 | elif (clidb.atype or clidb.totp) and open_databases: 143 | # If only autotype or totp is passed, use current db 144 | db_ = [i for i in open_databases.values() if i.is_active is True][0] 145 | dbs = [copy(db_)] 146 | dbs[0].atype = clidb.atype 147 | dbs[0].totp = clidb.totp 148 | elif open_databases: 149 | # if there are dbs already open, make a list of those + dbs from config.ini 150 | dbs = [copy(i) for i in open_databases.values()] 151 | for db_ in dbs_cfg: 152 | if db_.dbase not in open_databases: 153 | dbs.append(copy(db_)) 154 | else: 155 | dbs = dbs_cfg 156 | if len(dbs) > 1: 157 | inp = "\n".join(i.dbase for i in dbs) + "\nCreate Database" 158 | sel = dmenu_select(len(dbs) + 1, "Select Database", inp=inp) 159 | dbs = [i for i in dbs if i.dbase == sel] 160 | if sel == "Create Database": 161 | kpo = create_db() 162 | db_ = DataBase(dbase=kpo.filename, 163 | kfile=kpo.keyfile, 164 | pword=kpo.password) 165 | dbs.append(db_) 166 | if not sel or not dbs: 167 | return None, open_databases 168 | if dbs[0].pword is None: 169 | dbs[0].pword = get_passphrase() 170 | if dbs[0].pword is None: 171 | return None, open_databases 172 | if dbs[0].kpo is None: 173 | dbs[0].kpo = get_entries(dbs[0]) 174 | for db_ in open_databases.values(): 175 | db_.is_active = False 176 | if dbs[0].dbase not in open_databases: 177 | open_databases[dbs[0].dbase] = copy(dbs[0]) 178 | if dbs[0].dbase in dbs_cfg_n: 179 | db_cfg_atype = dbs_cfg[dbs_cfg_n.index(dbs[0].dbase)].atype 180 | open_databases[dbs[0].dbase].atype = db_cfg_atype 181 | if not dbs[0].atype: 182 | dbs[0].atype = db_cfg_atype 183 | if clidb.atype: 184 | dbs[0].atype = clidb.atype 185 | if clidb.totp: 186 | dbs[0].totp = clidb.totp 187 | open_databases[dbs[0].dbase].is_active = True 188 | return dbs[0], open_databases 189 | 190 | 191 | def get_initial_db(): 192 | """Ask for and set initial database name and keyfile if not entered in 193 | config file. Create new database if desired. 194 | 195 | """ 196 | db_name = dmenu_select(0, "Enter path to existing " 197 | "Keepass database or to create new database. " 198 | "~/ for $HOME is ok") 199 | if not db_name: 200 | dmenu_err("No database entered. Try again.") 201 | return False 202 | if not isfile(expanduser(db_name)): 203 | create = dmenu_select(0, f"Create new database {db_name} (y/n)?") 204 | if create.lower() == "y": 205 | kpo = create_db(db_name) 206 | keyfile_name = kpo.keyfile 207 | else: 208 | dmenu_err("Database not created. Try again.") 209 | return False 210 | else: 211 | keyfile_name = dmenu_select(0, "Enter path to keyfile (optional). ~/ for $HOME is ok") 212 | 213 | with open(keepmenu.CONF_FILE, 'w', encoding=keepmenu.ENC) as conf_file: 214 | keepmenu.CONF.set('database', 'database_1', db_name) 215 | if keyfile_name: 216 | keepmenu.CONF.set('database', 'keyfile_1', keyfile_name) 217 | keepmenu.CONF.write(conf_file) 218 | return True 219 | 220 | 221 | def get_entries(dbo): 222 | """Open keepass database and return the PyKeePass object 223 | 224 | Args: dbo: DataBase object 225 | Returns: PyKeePass object or None 226 | 227 | """ 228 | from pykeepass import PyKeePass # pylint: disable=import-outside-toplevel 229 | if dbo.dbase is None: 230 | return None 231 | try: 232 | kpo = PyKeePass(dbo.dbase, dbo.pword, keyfile=dbo.kfile) 233 | except (FileNotFoundError, construct.core.ChecksumError) as err: 234 | if str(err.args[0]).startswith("wrong checksum"): 235 | dmenu_err("Invalid Password or keyfile") 236 | return None 237 | try: 238 | if err.errno == errno.ENOENT: 239 | if not isfile(dbo.dbase): 240 | dmenu_err("Database does not exist. Check path and filename.") 241 | elif not isfile(dbo.kfile): 242 | dmenu_err("Keyfile does not exist. Check path and filename.") 243 | except AttributeError: 244 | pass 245 | return None 246 | except Exception as err: # pylint: disable=broad-except 247 | dmenu_err(f"Error: {err}") 248 | return None 249 | return kpo 250 | 251 | 252 | def get_passphrase(check=False): 253 | """Get a database password from dmenu or pinentry 254 | 255 | Returns: string 256 | 257 | """ 258 | msg = "Enter Password" if check is False else "Verify password" 259 | pinentry = keepmenu.CONF.get("dmenu", "pinentry", fallback=None) 260 | if pinentry: 261 | password = "" 262 | res = subprocess.run(pinentry, 263 | capture_output=True, 264 | check=False, 265 | encoding=keepmenu.ENC, 266 | input=f'SETDESC {msg}\nGETPIN\n') 267 | if res.stdout: 268 | pin = res.stdout.split("\n")[2] 269 | if pin.startswith("D "): 270 | password = pin.split("D ")[1] 271 | else: 272 | password = dmenu_select(0, f"{msg}") 273 | return None or password 274 | 275 | 276 | def get_expiring_entries(entries): 277 | """Return a list of expired entries or that will expire in the next 3 days (if they can expire) 278 | 279 | """ 280 | expiring = [] 281 | for entry in entries: 282 | if entry.expires is True and \ 283 | entry.expiry_time.timestamp() < (datetime.now() + timedelta(days=3)).timestamp(): 284 | expiring.append(entry) 285 | return expiring 286 | 287 | 288 | class DmenuRunner(Process): 289 | """Listen for dmenu calling event and run keepmenu 290 | 291 | Args: server - Server object 292 | kpo - Keepass object 293 | """ 294 | 295 | def __init__(self, server, **kwargs): 296 | Process.__init__(self) 297 | cfile = kwargs.get('config') 298 | keepmenu.CLIPBOARD = kwargs.get('clipboard', False) 299 | keepmenu.reload_config(None if cfile is None else expanduser(cfile)) 300 | self.server = server 301 | self.database, self.open_databases = get_database(**kwargs) 302 | if not self.database or not self.database.kpo: 303 | self.server.kill_flag.set() 304 | sys.exit() 305 | self.expiring = get_expiring_entries(self.database.kpo.entries) 306 | self.prev_entry = None 307 | 308 | def _set_timer(self): 309 | """Set inactivity timer 310 | 311 | """ 312 | # pylint: disable=attribute-defined-outside-init 313 | self.cache_timer = Timer(keepmenu.CACHE_PERIOD_MIN * 60, self.cache_time) 314 | self.cache_timer.daemon = True 315 | self.cache_timer.start() 316 | 317 | def run(self): 318 | while True: 319 | self.server.start_flag.wait() 320 | if self.server.kill_flag.is_set(): 321 | break 322 | if not self.database or not self.database.kpo: 323 | pass 324 | elif self.server.args_flag.is_set(): 325 | dargs = self.server.get_args() 326 | keepmenu.CLIPBOARD = dargs.get('clipboard', False) or keepmenu.CLIPBOARD 327 | self.menu_open_another_database(**dargs) 328 | self.server.args_flag.clear() 329 | 330 | if self.server.totp_flag.is_set(): 331 | self.server.totp_flag.clear() 332 | else: 333 | self.dmenu_run(self.server.totp_flag.is_set()) 334 | self.server.totp_flag.clear() 335 | if self.server.cache_time_expired.is_set(): 336 | self.server.kill_flag.set() 337 | if self.server.kill_flag.is_set(): 338 | break 339 | self.server.start_flag.clear() 340 | 341 | def cache_time(self): 342 | """Kill keepmenu daemon when cache timer expires 343 | 344 | """ 345 | self.server.cache_time_expired.set() 346 | self.server.args_flag.clear() 347 | if not self.server.start_flag.is_set(): 348 | self.server.kill_flag.set() 349 | self.server.start_flag.set() 350 | 351 | def dmenu_run(self, totp_mode=False): 352 | """Run dmenu with the given list of Keepass Entry objects 353 | 354 | If 'hide_groups' is defined in config.ini, hide those from main and 355 | view/type all views. 356 | 357 | Args: self.database.kpo - Keepass object 358 | 359 | """ 360 | try: 361 | self.cache_timer.cancel() 362 | except AttributeError: 363 | pass 364 | self._set_timer() 365 | if keepmenu.CONF.has_option("database", "hide_groups"): 366 | hid_groups = keepmenu.CONF.get("database", "hide_groups").split("\n") 367 | # Validate ignored group names in config.ini 368 | hid_groups = [i for i in hid_groups if i in 369 | [j.name for j in self.database.kpo.groups]] 370 | else: 371 | hid_groups = [] 372 | 373 | filtered_entries = [ 374 | i for i in self.database.kpo.entries if not 375 | any(j in "/".join(i.path[:-1]) for j in hid_groups) 376 | ] 377 | clip = "[Clipboard]/Type" if keepmenu.CLIPBOARD is True else "Clipboard/[Type]" 378 | options = { 379 | 'View/Type Individual entries': 380 | functools.partial(self.menu_view_type_individual_entries, hid_groups), 381 | 'View previous entry': self.menu_view_previous_entry, 382 | f'Edit expiring/expired passwords ({len(self.expiring)})': 383 | functools.partial(self.menu_edit_entries, self.expiring), 384 | 'Edit entries': functools.partial(self.menu_edit_entries, self.database.kpo.entries), 385 | 'Add entry': self.menu_add_entry, 386 | 'Manage groups': self.menu_manage_groups, 387 | 'Reload database': self.menu_reload_database, 388 | 'Open/create another database': self.menu_open_another_database, 389 | clip: self.menu_clipboard, 390 | 'Kill Keepmenu daemon': self.menu_kill_daemon, 391 | } 392 | if self.prev_entry is None: 393 | del options['View previous entry'] 394 | if len(self.expiring) == 0: 395 | del options['Edit expiring/expired passwords (0)'] 396 | 397 | if totp_mode: 398 | sel = self.menu_view_type_individual_entries(hid_groups, totp_only=True) 399 | else: 400 | sel = view_all_entries(list(options), filtered_entries, self.database.dbase) 401 | 402 | if not sel: 403 | return 404 | if sel in options: 405 | func = options[sel] 406 | func() 407 | else: 408 | try: 409 | entry = filtered_entries[int(sel.split('-', 1)[0])] 410 | except (ValueError, TypeError): 411 | return 412 | type_entry(entry, self.database.atype) 413 | self.prev_entry = entry 414 | # Reset database autotype and totp in between runs 415 | cur_db = [i for i in self.open_databases.values() if i.is_active is True][0] 416 | self.database.atype = cur_db.atype 417 | self.database.totp = cur_db.totp 418 | 419 | def menu_view_type_individual_entries(self, hid_groups, totp_only=False): 420 | """Process menu entry - View/Type individual entries 421 | 422 | """ 423 | options = [] 424 | filtered_entries = [ 425 | i for i in self.database.kpo.entries if not 426 | any(j in "/".join(i.path[:-1]) for j in hid_groups) and (get_otp_url(i) if totp_only else True) 427 | ] 428 | sel = view_all_entries(options, filtered_entries, self.database.dbase) 429 | try: 430 | entry = filtered_entries[int(sel.split('-', 1)[0])] 431 | except (ValueError, TypeError): 432 | return 433 | text = gen_otp(get_otp_url(entry)) if totp_only else view_entry(entry) 434 | type_text(text) 435 | self.prev_entry = entry 436 | 437 | def menu_view_previous_entry(self): 438 | """Process menu entry - View previous entry 439 | 440 | """ 441 | assert self.prev_entry is not None 442 | text = view_entry(self.prev_entry) 443 | type_text(text) 444 | 445 | def menu_edit_entries(self, entries): 446 | """Process menu entry - Edit individual entries 447 | 448 | """ 449 | options = [] 450 | sel = view_all_entries(options, entries, self.database.dbase) 451 | try: 452 | entry = entries[int(sel.split('-', 1)[0])] 453 | except (ValueError, TypeError): 454 | return 455 | edit = True 456 | while edit is True: 457 | edit = edit_entry(self.database.kpo, entry) 458 | self.database.kpo.save() 459 | self.expiring = get_expiring_entries(self.database.kpo.entries) 460 | self.prev_entry = entry if edit != "del" else None 461 | 462 | def menu_add_entry(self): 463 | """Process menu entry - Add entry 464 | 465 | """ 466 | entry = add_entry(self.database.kpo) 467 | if entry: 468 | self.database.kpo.save() 469 | self.prev_entry = entry 470 | 471 | 472 | def menu_manage_groups(self): 473 | """Process menu entry - manage groups 474 | 475 | """ 476 | group = manage_groups(self.database.kpo) 477 | if group: 478 | self.database.kpo.save() 479 | 480 | def menu_reload_database(self): 481 | """Process menu entry - Reload database 482 | 483 | """ 484 | self.database.kpo = get_entries(self.database) 485 | if not self.database.kpo: 486 | return 487 | self.expiring = get_expiring_entries(self.database.kpo.entries) 488 | self.dmenu_run() 489 | 490 | def menu_open_another_database(self, **kwargs): 491 | """Process menu entry - Open/create different database 492 | 493 | Args: kwargs - possibly 'database', 'keyfile', 'autotype', 'totp' 494 | 495 | """ 496 | prev_db = copy(self.database) 497 | self.database, self.open_databases = get_database(self.open_databases, **kwargs) 498 | if self.database is None or self.database.kpo is None: 499 | self.database = copy(prev_db) 500 | _ = self.open_databases.popitem() 501 | self.open_databases[self.database.dbase].is_active = True 502 | return 503 | self.expiring = get_expiring_entries(self.database.kpo.entries) 504 | self.dmenu_run(self.database.totp) 505 | 506 | def menu_clipboard(self): 507 | """Process menu entry - Toggle clipboard entry 508 | 509 | """ 510 | keepmenu.CLIPBOARD = not keepmenu.CLIPBOARD 511 | self.dmenu_run() 512 | 513 | def menu_kill_daemon(self): 514 | """Process menu entry - Kill keepmenu daemon 515 | 516 | """ 517 | try: 518 | self.server.kill_flag.set() 519 | except (EOFError, IOError): 520 | return 521 | 522 | 523 | # vim: set et ts=4 sw=4 : 524 | -------------------------------------------------------------------------------- /keepmenu/menu.py: -------------------------------------------------------------------------------- 1 | """Launcher functions 2 | 3 | """ 4 | from os.path import basename 5 | import shlex 6 | from subprocess import run 7 | 8 | import keepmenu 9 | 10 | 11 | def dmenu_cmd(num_lines, prompt): 12 | """Parse config.ini for dmenu options 13 | 14 | Args: args - num_lines: number of lines to display 15 | prompt: prompt to show 16 | Returns: command invocation (as a list of strings) for 17 | ["dmenu", "-l", "", "-p", "", "-i", ...] 18 | 19 | """ 20 | commands = {"bemenu": ["-p", str(prompt), "-l", str(num_lines)], 21 | "dmenu": ["-p", str(prompt), "-l", str(num_lines)], 22 | "wmenu": ["-p", str(prompt), "-l", str(num_lines)], 23 | "rofi": ["-dmenu", "-p", str(prompt), "-l", str(num_lines)], 24 | "tofi": ["--require-match=false", 25 | f"--prompt-text={str(prompt)}: ", 26 | f"--num-results={str(num_lines)}"], 27 | "wofi": ["--dmenu", "-p", str(prompt), "-L", str(num_lines + 1)], 28 | "yofi": ["-p", str(prompt), "dialog"], 29 | "fuzzel": ["-p", str(prompt) + " ", "-l", str(num_lines)]} 30 | command = shlex.split(keepmenu.CONF.get('dmenu', 'dmenu_command', fallback='dmenu')) 31 | command.extend(commands.get(basename(command[0]), [])) 32 | pwprompts = ("Password", "password", "client_secret", "Verify password", "Enter Password") 33 | obscure = keepmenu.CONF.getboolean('dmenu_passphrase', 'obscure', fallback=True) 34 | if any(i == prompt for i in pwprompts) and obscure is True: 35 | pass_prompts = {"dmenu": dmenu_pass(basename(command[0])), 36 | "wmenu": dmenu_pass(basename(command[0])), 37 | "rofi": ['-password'], 38 | "bemenu": ['-x', 'indicator', '*'], 39 | "tofi": ["--hide-input=true", "--hidden-character=*"], 40 | "wofi": ['-P'], 41 | "yofi": ['--password'], 42 | "fuzzel": ['--password']} 43 | command.extend(pass_prompts.get(basename(command[0]), [])) 44 | return command 45 | 46 | 47 | def dmenu_pass(command): 48 | """Check if dmenu passphrase patch is applied and return the correct command 49 | line arg list for wmenu or dmenu 50 | 51 | Args: command - string 52 | Returns: list or None 53 | 54 | """ 55 | if command not in ('dmenu', 'wmenu'): 56 | return None 57 | try: 58 | # Check for dmenu password patch 59 | dm_patch = b'P' in run([command, "-h"], 60 | capture_output=True, 61 | check=False).stderr 62 | except FileNotFoundError: 63 | dm_patch = False 64 | color = keepmenu.CONF.get('dmenu_passphrase', 'obscure_color', fallback="#222222") 65 | dargs = { "dmenu": ["-nb", color, "-nf", color], 66 | "wmenu": ["-n", color, "-N", color] } 67 | return ["-P"] if dm_patch else dargs[command] 68 | 69 | 70 | def dmenu_select(num_lines, prompt="Entries", inp=""): 71 | """Call dmenu and return the selected entry 72 | 73 | Args: num_lines - number of lines to display 74 | prompt - prompt to show 75 | inp - bytes string to pass to dmenu via STDIN 76 | 77 | Returns: sel - string 78 | 79 | """ 80 | cmd = dmenu_cmd(num_lines, prompt) 81 | res = run(cmd, 82 | capture_output=True, 83 | check=False, 84 | encoding=keepmenu.ENC, 85 | env=keepmenu.ENV, 86 | input=inp) 87 | return res.stdout.rstrip('\n') if res.stdout is not None else None 88 | 89 | 90 | def dmenu_err(prompt): 91 | """Pops up a dmenu prompt with an error message 92 | 93 | """ 94 | return dmenu_select(1, prompt) 95 | -------------------------------------------------------------------------------- /keepmenu/tokens_dotool.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | AUTOTYPE_TOKENS = { 3 | "{TAB}" : ['key', 'tab'], 4 | "{ENTER}" : ['key', 'enter'], 5 | "~" : ['key', 'enter'], 6 | "{UP}" : ['key', 'up'], 7 | "{DOWN}" : ['key', 'down'], 8 | "{LEFT}" : ['key', 'left'], 9 | "{RIGHT}" : ['key', 'right'], 10 | "{INSERT}" : ['key', 'insert'], 11 | "{INS}" : ['key', 'insert'], 12 | "{DELETE}" : ['key', 'delete'], 13 | "{DEL}" : ['key', 'delete'], 14 | "{HOME}" : ['key', 'home'], 15 | "{END}" : ['key', 'end'], 16 | "{PGUP}" : ['key', 'pageup'], 17 | "{PGDN}" : ['key', 'pagedown'], 18 | "{SPACE}" : ['type', ' '], 19 | "{BACKSPACE}" : ['key', 'backspace'], 20 | "{BS}" : ['key', 'backspace'], 21 | "{BKSP}" : ['key', 'backspace'], 22 | "{BREAK}" : ['key', 'pause'], 23 | "{CAPSLOCK}" : ['key', 'capslock'], 24 | "{ESC}" : ['key', 'esc'], 25 | "{WIN}" : ['key', 'super'], 26 | "{LWIN}" : ['key', 'leftmeta'], 27 | "{RWIN}" : ['key', 'rightmeta'], 28 | "{HELP}" : ['key', 'help'], 29 | "{NUMLOCK}" : ['key', 'numlock'], 30 | "{PRTSC}" : ['key', 'print'], 31 | "{SCROLLLOCK}": ['key', 'scolllock'], 32 | "{F1}" : ['key', 'f1'], 33 | "{F2}" : ['key', 'f2'], 34 | "{F3}" : ['key', 'f3'], 35 | "{F4}" : ['key', 'f4'], 36 | "{F5}" : ['key', 'f5'], 37 | "{F6}" : ['key', 'f6'], 38 | "{F7}" : ['key', 'f7'], 39 | "{F8}" : ['key', 'f8'], 40 | "{F9}" : ['key', 'f9'], 41 | "{F10}" : ['key', 'f10'], 42 | "{F11}" : ['key', 'f11'], 43 | "{F12}" : ['key', 'f12'], 44 | "{F13}" : ['key', 'f13'], 45 | "{F14}" : ['key', 'f14'], 46 | "{F15}" : ['key', 'f15'], 47 | "{F16}" : ['key', 'f16'], 48 | "{ADD}" : ['key', 'kpplus'], 49 | "{SUBTRACT}" : ['key', 'kpminus'], 50 | "{MULTIPLY}" : ['key', 'kpasterisk'], 51 | "{DIVIDE}" : ['key', 'slash'], 52 | "{NUMPAD0}" : ['key', 'kp0'], 53 | "{NUMPAD1}" : ['key', 'kp1'], 54 | "{NUMPAD2}" : ['key', 'kp2'], 55 | "{NUMPAD3}" : ['key', 'kp3'], 56 | "{NUMPAD4}" : ['key', 'kp4'], 57 | "{NUMPAD5}" : ['key', 'kp5'], 58 | "{NUMPAD6}" : ['key', 'kp6'], 59 | "{NUMPAD7}" : ['key', 'kp7'], 60 | "{NUMPAD8}" : ['key', 'kp8'], 61 | "{NUMPAD9}" : ['key', 'kp9'], 62 | "+" : ['key', 'shift'], 63 | "^" : ['Key', 'ctrl'], 64 | "%" : ['key', 'alt'], 65 | "@" : ['key', 'super'], 66 | } 67 | -------------------------------------------------------------------------------- /keepmenu/tokens_pynput.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from pynput import keyboard 3 | 4 | AUTOTYPE_TOKENS = { 5 | "{TAB}" : keyboard.Key.tab, 6 | "{ENTER}" : keyboard.Key.enter, 7 | "~" : keyboard.Key.enter, 8 | "{UP}" : keyboard.Key.up, 9 | "{DOWN}" : keyboard.Key.down, 10 | "{LEFT}" : keyboard.Key.left, 11 | "{RIGHT}" : keyboard.Key.right, 12 | "{INSERT}" : keyboard.Key.insert, 13 | "{INS}" : keyboard.Key.insert, 14 | "{DELETE}" : keyboard.Key.delete, 15 | "{DEL}" : keyboard.Key.delete, 16 | "{HOME}" : keyboard.Key.home, 17 | "{END}" : keyboard.Key.end, 18 | "{PGUP}" : keyboard.Key.page_up, 19 | "{PGDN}" : keyboard.Key.page_down, 20 | "{SPACE}" : keyboard.Key.space, 21 | "{BACKSPACE}" : keyboard.Key.backspace, 22 | "{BS}" : keyboard.Key.backspace, 23 | "{BKSP}" : keyboard.Key.backspace, 24 | "{BREAK}" : keyboard.Key.pause, 25 | "{CAPSLOCK}" : keyboard.Key.caps_lock, 26 | "{ESC}" : keyboard.Key.esc, 27 | "{WIN}" : keyboard.Key.cmd, 28 | "{LWIN}" : keyboard.Key.cmd_l, 29 | "{RWIN}" : keyboard.Key.cmd_r, 30 | # "{APPS}" : keyboard.Key. 31 | # "{HELP}" : keyboard.Key. 32 | "{NUMLOCK}" : keyboard.Key.num_lock, 33 | "{PRTSC}" : keyboard.Key.print_screen, 34 | "{SCROLLLOCK}": keyboard.Key.scroll_lock, 35 | "{F1}" : keyboard.Key.f1, 36 | "{F2}" : keyboard.Key.f2, 37 | "{F3}" : keyboard.Key.f3, 38 | "{F4}" : keyboard.Key.f4, 39 | "{F5}" : keyboard.Key.f5, 40 | "{F6}" : keyboard.Key.f6, 41 | "{F7}" : keyboard.Key.f7, 42 | "{F8}" : keyboard.Key.f8, 43 | "{F9}" : keyboard.Key.f9, 44 | "{F10}" : keyboard.Key.f10, 45 | "{F11}" : keyboard.Key.f11, 46 | "{F12}" : keyboard.Key.f12, 47 | "{F13}" : keyboard.Key.f13, 48 | "{F14}" : keyboard.Key.f14, 49 | "{F15}" : keyboard.Key.f15, 50 | "{F16}" : keyboard.Key.f16, 51 | # "{ADD}" : keyboard.Key. 52 | # "{SUBTRACT}" : keyboard.Key. 53 | # "{MULTIPLY}" : keyboard.Key. 54 | # "{DIVIDE}" : keyboard.Key. 55 | # "{NUMPAD0}" : keyboard.Key. 56 | # "{NUMPAD1}" : keyboard.Key. 57 | # "{NUMPAD2}" : keyboard.Key. 58 | # "{NUMPAD3}" : keyboard.Key. 59 | # "{NUMPAD4}" : keyboard.Key. 60 | # "{NUMPAD5}" : keyboard.Key. 61 | # "{NUMPAD6}" : keyboard.Key. 62 | # "{NUMPAD7}" : keyboard.Key. 63 | # "{NUMPAD8}" : keyboard.Key. 64 | # "{NUMPAD9}" : keyboard.Key. 65 | "+" : keyboard.Key.shift, 66 | "^" : keyboard.Key.ctrl, 67 | "%" : keyboard.Key.alt, 68 | "@" : keyboard.Key.cmd, 69 | } 70 | -------------------------------------------------------------------------------- /keepmenu/tokens_wtype.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | AUTOTYPE_TOKENS = { 3 | "{TAB}" : 'Tab', 4 | "{ENTER}" : 'Return', 5 | "~" : 'Return', 6 | "{UP}" : 'Up', 7 | "{DOWN}" : 'Down', 8 | "{LEFT}" : 'Left', 9 | "{RIGHT}" : 'Right', 10 | "{INSERT}" : 'Insert', 11 | "{INS}" : 'Insert', 12 | "{DELETE}" : 'Delete', 13 | "{DEL}" : 'Delete', 14 | "{HOME}" : 'Home', 15 | "{END}" : 'End', 16 | "{PGUP}" : 'Page_Up', 17 | "{PGDN}" : 'Page_Down', 18 | "{SPACE}" : 'Space', 19 | "{BACKSPACE}" : 'BackSpace', 20 | "{BS}" : 'BackSpace', 21 | "{BKSP}" : 'BackSpace', 22 | "{BREAK}" : 'Break', 23 | "{CAPSLOCK}" : 'Caps_Lock', 24 | "{ESC}" : 'Escape', 25 | "{WIN}" : 'Meta_L', 26 | "{LWIN}" : 'Meta_L', 27 | "{RWIN}" : 'Meta_R', 28 | # "{APPS}" : '', 29 | "{HELP}" : 'Help', 30 | "{NUMLOCK}" : 'Num_Lock', 31 | "{PRTSC}" : 'Print', 32 | "{SCROLLLOCK}": 'Scroll_Lock', 33 | "{F1}" : 'F1', 34 | "{F2}" : 'F2', 35 | "{F3}" : 'F3', 36 | "{F4}" : 'F4', 37 | "{F5}" : 'F5', 38 | "{F6}" : 'F6', 39 | "{F7}" : 'F7', 40 | "{F8}" : 'F8', 41 | "{F9}" : 'F9', 42 | "{F10}" : 'F10', 43 | "{F11}" : 'F11', 44 | "{F12}" : 'F12', 45 | "{F13}" : 'F13', 46 | "{F14}" : 'F14', 47 | "{F15}" : 'F15', 48 | "{F16}" : 'F16', 49 | "{ADD}" : 'KP_Add', 50 | "{SUBTRACT}" : 'KP_Subtract', 51 | "{MULTIPLY}" : 'KP_Multiply', 52 | "{DIVIDE}" : 'KP_Divide', 53 | "{NUMPAD0}" : 'KP_0', 54 | "{NUMPAD1}" : 'KP_1', 55 | "{NUMPAD2}" : 'KP_2', 56 | "{NUMPAD3}" : 'KP_3', 57 | "{NUMPAD4}" : 'KP_4', 58 | "{NUMPAD5}" : 'KP_5', 59 | "{NUMPAD6}" : 'KP_6', 60 | "{NUMPAD7}" : 'KP_7', 61 | "{NUMPAD8}" : 'KP_8', 62 | "{NUMPAD9}" : 'KP_9', 63 | "+" : 'Shift_L', 64 | "^" : 'Ctrl_L', 65 | "%" : 'Alt_L', 66 | "@" : 'Meta_L', 67 | } 68 | -------------------------------------------------------------------------------- /keepmenu/tokens_xdotool.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | AUTOTYPE_TOKENS = { 3 | "{TAB}" : ['key', 'Tab'], 4 | "{ENTER}" : ['key', 'Return'], 5 | "~" : ['key', 'Return'], 6 | "{UP}" : ['key', 'Up'], 7 | "{DOWN}" : ['key', 'Down'], 8 | "{LEFT}" : ['key', 'Left'], 9 | "{RIGHT}" : ['key', 'Right'], 10 | "{INSERT}" : ['key', 'Insert'], 11 | "{INS}" : ['key', 'Insert'], 12 | "{DELETE}" : ['key', 'Delete'], 13 | "{DEL}" : ['key', 'Delete'], 14 | "{HOME}" : ['key', 'Home'], 15 | "{END}" : ['key', 'End'], 16 | "{PGUP}" : ['key', 'Page_Up'], 17 | "{PGDN}" : ['key', 'Page_Down'], 18 | "{SPACE}" : ['type', ' '], 19 | "{BACKSPACE}" : ['key', 'BackSpace'], 20 | "{BS}" : ['key', 'BackSpace'], 21 | "{BKSP}" : ['key', 'BackSpace'], 22 | "{BREAK}" : ['key', 'Break'], 23 | "{CAPSLOCK}" : ['key', 'Caps_Lock'], 24 | "{ESC}" : ['key', 'Escape'], 25 | "{WIN}" : ['key', 'Super'], 26 | "{LWIN}" : ['key', 'Super_L'], 27 | "{RWIN}" : ['key', 'Super_R'], 28 | # "{APPS}" : ['key', ''], 29 | # "{HELP}" : ['key', ''], 30 | "{NUMLOCK}" : ['key', 'Num_Lock'], 31 | # "{PRTSC}" : ['key', ''], 32 | "{SCROLLLOCK}": ['key', 'Scroll_Lock'], 33 | "{F1}" : ['key', 'F1'], 34 | "{F2}" : ['key', 'F2'], 35 | "{F3}" : ['key', 'F3'], 36 | "{F4}" : ['key', 'F4'], 37 | "{F5}" : ['key', 'F5'], 38 | "{F6}" : ['key', 'F6'], 39 | "{F7}" : ['key', 'F7'], 40 | "{F8}" : ['key', 'F8'], 41 | "{F9}" : ['key', 'F9'], 42 | "{F10}" : ['key', 'F10'], 43 | "{F11}" : ['key', 'F11'], 44 | "{F12}" : ['key', 'F12'], 45 | "{F13}" : ['key', 'F13'], 46 | "{F14}" : ['key', 'F14'], 47 | "{F15}" : ['key', 'F15'], 48 | "{F16}" : ['key', 'F16'], 49 | "{ADD}" : ['key', 'KP_Add'], 50 | "{SUBTRACT}" : ['key', 'KP_Subtract'], 51 | "{MULTIPLY}" : ['key', 'KP_Multiply'], 52 | "{DIVIDE}" : ['key', 'KP_Divide'], 53 | "{NUMPAD0}" : ['key', 'KP_0'], 54 | "{NUMPAD1}" : ['key', 'KP_1'], 55 | "{NUMPAD2}" : ['key', 'KP_2'], 56 | "{NUMPAD3}" : ['key', 'KP_3'], 57 | "{NUMPAD4}" : ['key', 'KP_4'], 58 | "{NUMPAD5}" : ['key', 'KP_5'], 59 | "{NUMPAD6}" : ['key', 'KP_6'], 60 | "{NUMPAD7}" : ['key', 'KP_7'], 61 | "{NUMPAD8}" : ['key', 'KP_8'], 62 | "{NUMPAD9}" : ['key', 'KP_9'], 63 | "+" : ['key', 'Shift'], 64 | "^" : ['Key', 'Ctrl'], 65 | "%" : ['key', 'Alt'], 66 | "@" : ['key', 'Super'], 67 | } 68 | -------------------------------------------------------------------------------- /keepmenu/tokens_ydotool.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | AUTOTYPE_TOKENS = { 3 | "{TAB}" : ['key', '15:1', '15:0'], 4 | "{ENTER}" : ['key', '28:1', '28:0'], 5 | "~" : ['key', '28:1', '28:0'], 6 | "{UP}" : ['key', '103:1', '103:0'], 7 | "{DOWN}" : ['key', '108:1', '108:0'], 8 | "{LEFT}" : ['key', '105:1', '105:0'], 9 | "{RIGHT}" : ['key', '106:1', '106:0'], 10 | "{INSERT}" : ['key', '110:1', '110:0'], 11 | "{INS}" : ['key', '110:1', '110:0'], 12 | "{DELETE}" : ['key', '111:1', '111:0'], 13 | "{DEL}" : ['key', '111:1', '111:0'], 14 | "{HOME}" : ['key', '102:1', '102:0'], 15 | "{END}" : ['key', '107:1', '107:0'], 16 | "{PGUP}" : ['key', '104:1', '104:0'], 17 | "{PGDN}" : ['key', '109:1', '109:0'], 18 | "{SPACE}" : ['key', '57:1', '57:0'], 19 | "{BACKSPACE}" : ['key', '14:1', '14:0'], 20 | "{BS}" : ['key', '14:1', '14:0'], 21 | "{BKSP}" : ['key', '14:1', '14:0'], 22 | "{BREAK}" : ['key', '0x19b:1', '0x19b:0'], 23 | "{CAPSLOCK}" : ['key', '58:1', '58:0'], 24 | "{ESC}" : ['key', '1:1', '1:0'], 25 | "{WIN}" : ['key', '125:1', '125:0'], 26 | "{LWIN}" : ['key', '125:1', '125:0'], 27 | "{RWIN}" : ['key', '126:1', '126:0'], 28 | "{APPS}" : ['key', '0x244:1', '0x244:0'], 29 | "{HELP}" : ['key', '138:1', '138:0'], 30 | "{NUMLOCK}" : ['key', '69:1', '69:0'], 31 | "{PRTSC}" : ['key', '99:1', '99:0'], 32 | "{SCROLLLOCK}": ['key', '70:1', '70:0'], 33 | "{F1}" : ['key', '59:1', '59:0'], 34 | "{F2}" : ['key', '60:1', '60:0'], 35 | "{F3}" : ['key', '61:1', '61:0'], 36 | "{F4}" : ['key', '62:1', '62:0'], 37 | "{F5}" : ['key', '63:1', '63:0'], 38 | "{F6}" : ['key', '64:1', '64:0'], 39 | "{F7}" : ['key', '65:1', '65:0'], 40 | "{F8}" : ['key', '66:1', '66:0'], 41 | "{F9}" : ['key', '67:1', '67:0'], 42 | "{F10}" : ['key', '68:1', '68:0'], 43 | "{F11}" : ['key', '87:1', '87:0'], 44 | "{F12}" : ['key', '88:1', '88:0'], 45 | "{F13}" : ['key', '183:1', '183:0'], 46 | "{F14}" : ['key', '184:1', '184:0'], 47 | "{F15}" : ['key', '185:1', '185:0'], 48 | "{F16}" : ['key', '186:1', '186:0'], 49 | "{ADD}" : ['key', '78:1', '78:0'], 50 | "{SUBTRACT}" : ['key', '74:1', '74:0'], 51 | "{MULTIPLY}" : ['key', '55:1', '55:0'], 52 | "{DIVIDE}" : ['key', '98:1', '98:0'], 53 | "{NUMPAD0}" : ['key', '82:1', '82:0'], 54 | "{NUMPAD1}" : ['key', '79:1', '79:0'], 55 | "{NUMPAD2}" : ['key', '80:1', '80:0'], 56 | "{NUMPAD3}" : ['key', '81:1', '81:0'], 57 | "{NUMPAD4}" : ['key', '75:1', '75:0'], 58 | "{NUMPAD5}" : ['key', '76:1', '76:0'], 59 | "{NUMPAD6}" : ['key', '77:1', '77:0'], 60 | "{NUMPAD7}" : ['key', '71:1', '71:0'], 61 | "{NUMPAD8}" : ['key', '72:1', '72:0'], 62 | "{NUMPAD9}" : ['key', '73:1', '73:0'], 63 | "+" : ['key', '42:1', '42:0'], 64 | "^" : ['Key', '29:1', '29:0'], 65 | "%" : ['key', '56:1', '56:0'], 66 | "@" : ['key', '125:1', '125:0'] 67 | } 68 | -------------------------------------------------------------------------------- /keepmenu/totp.py: -------------------------------------------------------------------------------- 1 | """ TOTP generation 2 | 3 | """ 4 | import base64 5 | import hmac 6 | import struct 7 | import time 8 | from urllib import parse 9 | 10 | 11 | TOTP_PUBLIC_FIELDS = ('TOTP Settings', 'TimeOtp-Length', 'TimeOtp-Period', 'TimeOtp-Algorithm') 12 | TOTP_SECRET_FIELDS = ('otp', 13 | 'TOTP Seed', 14 | 'TimeOtp-Secret', 15 | 'TimeOtp-Secret-Hex', 16 | 'TimeOtp-Secret-Base32', 17 | 'TimeOtp-Secret-Base64', 18 | 'HmacOtp-Secret', 19 | 'HmacOtp-Secret-Hex', 20 | 'HmacOtp-Secret-Base32', 21 | 'HmacOtp-Secret-Base64', 22 | 'HmacOtp-Counter') 23 | TOTP_FIELDS = TOTP_PUBLIC_FIELDS + TOTP_SECRET_FIELDS 24 | 25 | 26 | def hotp(key, counter, digits=6, digest='sha1', steam=False): 27 | """ Generates HMAC OTP. Taken from https://github.com/susam/mintotp 28 | 29 | Args: key - Secret key 30 | counter - Moving factor 31 | digits - The number of characters/digits that the otp should have 32 | digest - Algorithm to use to generate the otp 33 | steam - whether or not to use steam settings 34 | 35 | Returns: otp 36 | 37 | """ 38 | key = base64.b32decode(key.upper() + '=' * ((8 - len(key)) % 8)) 39 | counter = struct.pack('>Q', counter) 40 | mac = hmac.new(key, counter, digest).digest() 41 | offset = mac[-1] & 0x0f 42 | binary = struct.unpack('>L', mac[offset:offset + 4])[0] & 0x7fffffff 43 | code = '' 44 | 45 | if steam: 46 | chars = '23456789BCDFGHJKMNPQRTVWXY' 47 | full_code = int(binary) 48 | for _ in range(digits): 49 | code += chars[full_code % len(chars)] 50 | full_code //= len(chars) 51 | else: 52 | code = str(binary)[-digits:].rjust(digits, '0') 53 | 54 | return code 55 | 56 | 57 | def totp(key, time_step=30, digits=6, digest='sha1', steam=False): 58 | """ Generates Time Based OTP 59 | 60 | Args: key - Secret key 61 | time_step - The length of time in seconds each otp is valid for 62 | digits - The number of characters/digits that the otp should have 63 | digest - Algorithm to use to generate the otp 64 | steam - whether or not to use steam settings 65 | 66 | Returns: otp 67 | 68 | """ 69 | return hotp(key, int(time.time() / time_step), digits, digest, steam) 70 | 71 | 72 | def gen_otp(otp_url): 73 | """ Generates one time password 74 | 75 | Args: otp_url - KeePassXC url encoding with information on how to generate otp 76 | Returns: otp 77 | 78 | """ 79 | parsed_otp_url = parse.urlparse(otp_url) 80 | if parsed_otp_url.scheme == "otpauth": 81 | query_string = parse.parse_qs(parsed_otp_url.query) 82 | else: 83 | query_string = parse.parse_qs(otp_url) 84 | params = {} 85 | 86 | if 'secret' in query_string: 87 | params['key'] = query_string['secret'][0] 88 | try: 89 | params['time_step'] = int(query_string['periods'][0]) 90 | except KeyError: 91 | pass 92 | try: 93 | params['digits'] = int(query_string['digits'][0]) 94 | except KeyError: 95 | pass 96 | try: 97 | params['digest'] = query_string['algorithm'][0].lower() 98 | except KeyError: 99 | pass 100 | try: 101 | params["steam"] = query_string['encoder'][0] == "steam" 102 | except KeyError: 103 | pass 104 | # support keeotp format 105 | elif 'key' in query_string: 106 | params['key'] = query_string['key'][0] 107 | try: 108 | params['time_step'] = int(query_string['step'][0]) 109 | except KeyError: 110 | pass 111 | try: 112 | params['digits'] = int(query_string['size'][0]) 113 | except KeyError: 114 | pass 115 | try: 116 | params['digest'] = query_string['otpHashMode'][0].lower() 117 | except KeyError: 118 | pass 119 | else: 120 | return '' 121 | 122 | return totp(**params) 123 | 124 | 125 | def get_otp_url(kp_entry): 126 | """ Shim to return otp url from KeePass entry 127 | This is required to fully support pykeepass>=4.0.0 128 | "otp" was upgraded to a reserved property in pykeepass==4.0.3 129 | 130 | Args: kp_entry - KeePassXC entry 131 | Returns: otp url string or None 132 | 133 | """ 134 | otp_url = "" 135 | if hasattr(kp_entry, "otp"): 136 | otp_url = kp_entry.deref("otp") or "" 137 | else: 138 | otp_url = kp_entry.get_custom_property("otp") 139 | if otp_url: 140 | return otp_url 141 | 142 | otp_url_format = "otpauth://totp/Entry?secret={}&period={}&digits={}&algorithm={}" 143 | # Support some TOTP schemes that use custom properties "TOTP Seed" and "TOTP Settings" 144 | digits, period, algorithm = (6, 30, "sha1") 145 | seed = kp_entry.get_custom_property("TOTP Seed") 146 | if seed: 147 | settings = kp_entry.get_custom_property("TOTP Settings") or "" 148 | try: 149 | period, digits = settings.split(";") 150 | except ValueError: 151 | pass 152 | return otp_url_format.format(seed, period, digits, algorithm) 153 | 154 | # Support keepass2's default TOTP properties 155 | seed = kp_entry.get_custom_property("TimeOtp-Secret-Base32") 156 | if seed: 157 | period = int(kp_entry.get_custom_property("TimeOtp-Period") or period) 158 | digits = int(kp_entry.get_custom_property("TimeOtp-Length") or digits) 159 | algorithm = kp_entry.get_custom_property("TimeOtp-Algorithm") or algorithm 160 | algo_map = { 161 | "hmac-sha-1": "sha1", 162 | "hmac-sha-256": "sha256", 163 | "hmac-sha-512": "sha512", 164 | } 165 | algorithm = algo_map.get(algorithm.lower(), "sha1") 166 | return otp_url_format.format(seed, period, digits, algorithm) 167 | 168 | return otp_url 169 | -------------------------------------------------------------------------------- /keepmenu/type.py: -------------------------------------------------------------------------------- 1 | """Methods for typing entries with pynput, xdotool, ydotool, wtype, dotool 2 | 3 | """ 4 | # flake8: noqa 5 | # pylint: disable=import-outside-toplevel 6 | import re 7 | from shlex import split 8 | from subprocess import call, run 9 | from threading import Timer 10 | import time 11 | 12 | import keepmenu 13 | from keepmenu.menu import dmenu_err 14 | from keepmenu.totp import gen_otp, get_otp_url 15 | 16 | 17 | def tokenize_autotype(autotype): 18 | """Process the autotype sequence 19 | 20 | Args: autotype - string 21 | Returns: tokens - generator ((token, if_special_char T/F), ...) 22 | 23 | """ 24 | while autotype: 25 | opening_idx = -1 26 | for char in "{+^%~@": 27 | idx = autotype.find(char) 28 | if idx != -1 and (opening_idx == -1 or idx < opening_idx): 29 | opening_idx = idx 30 | 31 | if opening_idx == -1: 32 | # found the end of the string without further opening braces or 33 | # other characters 34 | yield autotype, False 35 | return 36 | 37 | if opening_idx > 0: 38 | yield autotype[:opening_idx], False 39 | 40 | if autotype[opening_idx] in "+^%~@": 41 | yield autotype[opening_idx], True 42 | autotype = autotype[opening_idx + 1:] 43 | continue 44 | 45 | closing_idx = autotype.find('}') 46 | if closing_idx == -1: 47 | dmenu_err("Unable to find matching right brace (}) while" + 48 | f"tokenizing auto-type string: {autotype}\n") 49 | return 50 | if closing_idx == opening_idx + 1 and closing_idx + 1 < len(autotype) \ 51 | and autotype[closing_idx + 1] == '}': 52 | yield "{}}", True 53 | autotype = autotype[closing_idx + 2:] 54 | continue 55 | yield autotype[opening_idx:closing_idx + 1], True 56 | autotype = autotype[closing_idx + 1:] 57 | 58 | 59 | def token_command(token): 60 | """When token denotes a special command, this function provides a callable 61 | implementing its behaviour. 62 | 63 | """ 64 | cmd = None 65 | 66 | def _check_delay(): 67 | match = re.match(r'{DELAY (\d+)}', token) 68 | if match: 69 | delay = match.group(1) 70 | nonlocal cmd 71 | cmd = lambda _, t=delay: time.sleep(int(t) / 1000) 72 | return True 73 | return False 74 | 75 | def _check_additional_attribute(): 76 | match = re.match(r'{S:(.*)}', token) 77 | if match: 78 | attr = match.group(1) 79 | nonlocal cmd 80 | cmd = lambda e, a=attr: e.get_custom_property(a) 81 | return True 82 | return False 83 | 84 | if _check_delay(): # {DELAY x} 85 | return cmd 86 | 87 | if _check_additional_attribute(): # {S:} 88 | return cmd 89 | 90 | return None 91 | 92 | 93 | def type_entry(entry, db_autotype=None): 94 | """Pick which library to use to type strings 95 | 96 | Defaults to pynput 97 | 98 | Args: entry - The entry to type 99 | db_autotype - the database specific autotype that overrides 'autotype_default' 100 | 101 | """ 102 | sequence = keepmenu.SEQUENCE 103 | if keepmenu.CLIPBOARD is True: 104 | if hasattr(entry, 'password'): 105 | type_clipboard(entry.password) 106 | else: 107 | dmenu_err("Clipboard is active. 'View/Type Individual entries' and select field to copy") 108 | return 109 | if hasattr(entry, 'autotype_enabled') and entry.autotype_enabled is False: 110 | dmenu_err("Autotype disabled for this entry") 111 | return 112 | if db_autotype is not None and db_autotype != '': 113 | sequence = db_autotype 114 | if hasattr(entry, 'autotype_sequence') and \ 115 | entry.autotype_sequence is not None and \ 116 | entry.autotype_sequence != 'None': 117 | sequence = entry.autotype_sequence 118 | tokens = tokenize_autotype(sequence) 119 | 120 | library = "pynput" 121 | libraries = {'pynput': type_entry_pynput, 122 | 'xdotool': type_entry_xdotool, 123 | 'ydotool': type_entry_ydotool, 124 | 'wtype': type_entry_wtype, 125 | 'dotool': type_entry_dotool} 126 | library = keepmenu.CONF.get('database', 'type_library', fallback='pynput') 127 | libraries.get(library, type_entry_pynput)(entry, tokens) 128 | 129 | PLACEHOLDER_AUTOTYPE_TOKENS = { 130 | "{TITLE}" : lambda e: e.deref('title') or "", 131 | "{USERNAME}": lambda e: e.deref('username') or "", 132 | "{URL}" : lambda e: e.deref('url') or "", 133 | "{PASSWORD}": lambda e: e.deref('password') or "", 134 | "{NOTES}" : lambda e: e.deref('notes') or "", 135 | "{TOTP}" : lambda e: gen_otp(get_otp_url(e)), 136 | "{TIMEOTP}" : lambda e: gen_otp(get_otp_url(e)), 137 | } 138 | 139 | STRING_AUTOTYPE_TOKENS = { 140 | "{PLUS}" : '+', 141 | "{PERCENT}" : '%', 142 | "{CARET}" : '^', 143 | "{TILDE}" : '~', 144 | "{LEFTPAREN}" : '(', 145 | "{RIGHTPAREN}": ')', 146 | "{LEFTBRACE}" : '{', 147 | "{RIGHTBRACE}": '}', 148 | "{AT}" : '@', 149 | "{+}" : '+', 150 | "{%}" : '%', 151 | "{^}" : '^', 152 | "{~}" : '~', 153 | "{(}" : '(', 154 | "{)}" : ')', 155 | "{[}" : '[', 156 | "{]}" : ']', 157 | "{{}" : '{', 158 | "{}}" : '}', 159 | } 160 | 161 | def type_entry_pynput(entry, tokens): # pylint: disable=too-many-branches 162 | """Use pynput to auto-type the selected entry 163 | 164 | """ 165 | try: 166 | from pynput import keyboard 167 | from .tokens_pynput import AUTOTYPE_TOKENS 168 | except ModuleNotFoundError: 169 | return 170 | kbd = keyboard.Controller() 171 | enter_idx = True 172 | for token, special in tokens: 173 | if special: 174 | cmd = token_command(token) 175 | if callable(cmd): 176 | to_type = cmd(entry) # pylint: disable=not-callable 177 | if to_type is not None: 178 | try: 179 | kbd.type(to_type) 180 | except kbd.InvalidCharacterException: 181 | dmenu_err("Unable to type string...bad character.\n" 182 | "Try setting `type_library = xdotool` in config.ini") 183 | elif token in PLACEHOLDER_AUTOTYPE_TOKENS: 184 | to_type = PLACEHOLDER_AUTOTYPE_TOKENS[token](entry) 185 | if to_type: 186 | try: 187 | kbd.type(to_type) 188 | except kbd.InvalidCharacterException: 189 | dmenu_err("Unable to type string...bad character.\n" 190 | "Try setting `type_library = xdotool` in config.ini") 191 | return 192 | elif token in STRING_AUTOTYPE_TOKENS: 193 | to_type = STRING_AUTOTYPE_TOKENS[token] 194 | try: 195 | kbd.type(to_type) 196 | except kbd.InvalidCharacterException: 197 | dmenu_err("Unable to type string...bad character.\n" 198 | "Try setting `type_library = xdotool` in config.ini") 199 | return 200 | elif token in AUTOTYPE_TOKENS: 201 | to_tap = AUTOTYPE_TOKENS[token] 202 | kbd.tap(to_tap) 203 | # Add extra {ENTER} key tap for first instance of {ENTER}. It 204 | # doesn't get recognized for some reason. 205 | if enter_idx is True and token in ("{ENTER}", "~"): 206 | kbd.tap(to_tap) 207 | enter_idx = False 208 | else: 209 | dmenu_err(f"Unsupported auto-type token (pynput): \"{token}\"") 210 | return 211 | else: 212 | try: 213 | kbd.type(token) 214 | except kbd.InvalidCharacterException: 215 | dmenu_err("Unable to type string...bad character.\n" 216 | "Try setting `type_library = xdotool` in config.ini") 217 | return 218 | 219 | 220 | def type_entry_xdotool(entry, tokens): 221 | """Auto-type entry entry using xdotool 222 | 223 | """ 224 | enter_idx = True 225 | from .tokens_xdotool import AUTOTYPE_TOKENS 226 | for token, special in tokens: 227 | if special: 228 | cmd = token_command(token) 229 | if callable(cmd): 230 | to_type = cmd(entry) # pylint: disable=not-callable 231 | if to_type is not None: 232 | call(['xdotool', 'type', '--', to_type]) 233 | elif token in PLACEHOLDER_AUTOTYPE_TOKENS: 234 | to_type = PLACEHOLDER_AUTOTYPE_TOKENS[token](entry) 235 | if to_type: 236 | call(['xdotool', 'type', '--', to_type]) 237 | elif token in STRING_AUTOTYPE_TOKENS: 238 | to_type = STRING_AUTOTYPE_TOKENS[token] 239 | call(['xdotool', 'type', '--', to_type]) 240 | elif token in AUTOTYPE_TOKENS: 241 | cmd = ['xdotool'] + AUTOTYPE_TOKENS[token] 242 | call(cmd) 243 | # Add extra {ENTER} key tap for first instance of {ENTER}. It 244 | # doesn't get recognized for some reason. 245 | if enter_idx is True and token in ("{ENTER}", "~"): 246 | cmd = ['xdotool'] + AUTOTYPE_TOKENS[token] 247 | call(cmd) 248 | enter_idx = False 249 | else: 250 | dmenu_err(f"Unsupported auto-type token (xdotool): \"{token}\"") 251 | return 252 | else: 253 | call(['xdotool', 'type', '--', token]) 254 | 255 | 256 | def type_entry_ydotool(entry, tokens): 257 | """Auto-type entry entry using ydotool 258 | 259 | """ 260 | from .tokens_ydotool import AUTOTYPE_TOKENS 261 | for token, special in tokens: 262 | if special: 263 | cmd = token_command(token) 264 | if callable(cmd): 265 | to_type = cmd(entry) # pylint: disable=not-callable 266 | if to_type is not None: 267 | call(['ydotool', 'type', '-e', '0', '--', to_type]) 268 | elif token in PLACEHOLDER_AUTOTYPE_TOKENS: 269 | to_type = PLACEHOLDER_AUTOTYPE_TOKENS[token](entry) 270 | if to_type: 271 | call(['ydotool', 'type', '-e', '0', '--', to_type]) 272 | elif token in STRING_AUTOTYPE_TOKENS: 273 | to_type = STRING_AUTOTYPE_TOKENS[token] 274 | call(['ydotool', 'type', '-e', '0', '--', to_type]) 275 | elif token in AUTOTYPE_TOKENS: 276 | cmd = ['ydotool'] + AUTOTYPE_TOKENS[token] 277 | call(cmd) 278 | else: 279 | dmenu_err(f"Unsupported auto-type token (ydotool): \"{token}\"") 280 | return 281 | else: 282 | call(['ydotool', 'type', '-e', '0', '--', token]) 283 | 284 | 285 | def type_entry_wtype(entry, tokens): 286 | """Auto-type entry entry using wtype 287 | 288 | """ 289 | from .tokens_wtype import AUTOTYPE_TOKENS 290 | for token, special in tokens: 291 | if special: 292 | cmd = token_command(token) 293 | if callable(cmd): 294 | to_type = cmd(entry) # pylint: disable=not-callable 295 | if to_type is not None: 296 | call(['wtype', '--', to_type]) 297 | elif token in PLACEHOLDER_AUTOTYPE_TOKENS: 298 | to_type = PLACEHOLDER_AUTOTYPE_TOKENS[token](entry) 299 | if to_type: 300 | call(['wtype', '--', to_type]) 301 | elif token in STRING_AUTOTYPE_TOKENS: 302 | to_type = STRING_AUTOTYPE_TOKENS[token] 303 | call(['wtype', '--', to_type]) 304 | elif token in AUTOTYPE_TOKENS: 305 | cmd = ['wtype', '-k', AUTOTYPE_TOKENS[token]] 306 | call(cmd) 307 | else: 308 | dmenu_err(f"Unsupported auto-type token (wtype): \"{token}\"") 309 | return 310 | else: 311 | call(['wtype', '--', token]) 312 | 313 | 314 | def type_entry_dotool(entry, tokens): 315 | """Auto-type entry entry using dotool 316 | 317 | """ 318 | from .tokens_dotool import AUTOTYPE_TOKENS 319 | for token, special in tokens: 320 | if special: 321 | cmd = token_command(token) 322 | if callable(cmd): 323 | to_type = cmd(entry) # pylint: disable=not-callable 324 | if to_type is not None: 325 | _ = run(['dotool'], check=True, encoding=keepmenu.ENC, input=f"type {to_type}") 326 | elif token in PLACEHOLDER_AUTOTYPE_TOKENS: 327 | to_type = PLACEHOLDER_AUTOTYPE_TOKENS[token](entry) 328 | if to_type: 329 | _ = run(['dotool'], check=True, encoding=keepmenu.ENC, input=f"type {to_type}") 330 | elif token in STRING_AUTOTYPE_TOKENS: 331 | to_type = STRING_AUTOTYPE_TOKENS[token] 332 | _ = run(['dotool'], check=True, encoding=keepmenu.ENC, input=f"type {to_type}") 333 | elif token in AUTOTYPE_TOKENS: 334 | to_type = " ".join(AUTOTYPE_TOKENS[token]) 335 | _ = run(['dotool'], check=True, encoding=keepmenu.ENC, input=to_type) 336 | else: 337 | dmenu_err(f"Unsupported auto-type token (dotool): \"{token}\"") 338 | return 339 | else: 340 | _ = run(['dotool'], check=True, encoding=keepmenu.ENC, input=f"type {token}") 341 | 342 | 343 | def type_text(data): 344 | """Type the given text data 345 | 346 | """ 347 | if keepmenu.CLIPBOARD is True: 348 | type_clipboard(data) 349 | return 350 | library = 'pynput' 351 | if keepmenu.CONF.has_option('database', 'type_library'): 352 | library = keepmenu.CONF.get('database', 'type_library') 353 | if library == 'xdotool': 354 | call(['xdotool', 'type', '--', data]) 355 | elif library == 'ydotool': 356 | call(['ydotool', 'type', '-e', '0', '--', data]) 357 | elif library == 'wtype': 358 | call(['wtype', '--', data]) 359 | elif library == 'dotool': 360 | _ = run(['dotool'], check=True, encoding=keepmenu.ENC, input=f"type {data}") 361 | else: 362 | try: 363 | from pynput import keyboard 364 | except ModuleNotFoundError: 365 | return 366 | kbd = keyboard.Controller() 367 | try: 368 | kbd.type(data) 369 | except kbd.InvalidCharacterException: 370 | dmenu_err("Unable to type string...bad character.\n" 371 | "Try setting `type_library = xdotool` in config.ini") 372 | 373 | 374 | def type_clipboard(text): 375 | """Copy text to clipboard and clear clipboard after 30 seconds 376 | 377 | Args: text - str 378 | 379 | """ 380 | text = text or "" # Handle None type 381 | run(split(keepmenu.CLIPBOARD_CMD), check=True, input=text.encode(keepmenu.ENC)) 382 | clear = Timer(30, lambda: run(split(keepmenu.CLIPBOARD_CMD), check=False, input="")) 383 | clear.start() 384 | -------------------------------------------------------------------------------- /keepmenu/view.py: -------------------------------------------------------------------------------- 1 | """Methods to view database items 2 | 3 | """ 4 | import os.path 5 | import webbrowser 6 | 7 | import keepmenu 8 | from keepmenu.menu import dmenu_select 9 | from keepmenu.totp import gen_otp, get_otp_url, TOTP_FIELDS 10 | 11 | 12 | def view_all_entries(options, kp_entries, dbname): 13 | """Generate numbered list of all Keepass entries and open with dmenu. 14 | 15 | Returns: dmenu selection 16 | 17 | """ 18 | num_align = len(str(len(kp_entries))) 19 | kp_entry_pattern = str("{:>{na}} - {} - {} - {}") # Path,username,url 20 | # Have to number each entry to capture duplicates correctly 21 | kps = str("\n").join([kp_entry_pattern.format(j, 22 | os.path.join("/".join(i.path[:-1]), 23 | i.deref('title') or ""), 24 | i.deref('username') or "", 25 | i.deref('url') or "", 26 | na=num_align) 27 | for j, i in enumerate(kp_entries)]) 28 | if options: 29 | options_s = "\n".join(options) + "\n" 30 | entries_s = options_s + kps 31 | else: 32 | entries_s = kps 33 | 34 | prompt = f"Entries: {dbname}" 35 | if keepmenu.CONF.has_option('dmenu', 'title_path'): 36 | try: 37 | max_length = keepmenu.CONF.getboolean('dmenu', 'title_path') 38 | except ValueError: 39 | max_length = keepmenu.CONF.getint('dmenu', 'title_path') 40 | prompt = generate_prompt(max_length, dbname) 41 | 42 | return dmenu_select(min(keepmenu.MAX_LEN, len(options) + len(kp_entries)), 43 | inp=entries_s, 44 | prompt=prompt) 45 | 46 | 47 | def view_entry(kp_entry): 48 | """Show title, username, password, url and notes for an entry. 49 | 50 | Returns: dmenu selection 51 | 52 | """ 53 | fields = [os.path.join("/".join(kp_entry.path[:-1]), kp_entry.deref('title') or "") 54 | or "Title: None", 55 | kp_entry.deref('username') or "Username: None", 56 | '**********' if kp_entry.deref('password') else "Password: None", 57 | "TOTP: ******" if get_otp_url(kp_entry) else "TOTP: None", 58 | kp_entry.deref('url') or "URL: None", 59 | "Notes: " if kp_entry.deref('notes') else "Notes: None", 60 | str(f"Expire time: {kp_entry.expiry_time}") 61 | if kp_entry.expires is True else "Expiry date: None"] 62 | 63 | attrs = kp_entry.custom_properties 64 | for attr in attrs: 65 | if attr not in TOTP_FIELDS: 66 | val = attrs.get(attr) or "" 67 | protected = kp_entry.is_custom_property_protected(attr) if \ 68 | hasattr(kp_entry, 'is_custom_property_protected') \ 69 | else False 70 | value = val or "None" if len(val.split('\n')) <= 1 and \ 71 | not protected \ 72 | else "" 73 | fields.append(f'{attr}: {value}') 74 | 75 | sel = dmenu_select(len(fields), inp="\n".join(fields)) 76 | if sel == "Notes: ": 77 | sel = view_notes(kp_entry.deref('notes') or "") 78 | elif sel == "Notes: None": 79 | sel = "" 80 | elif sel == '**********': 81 | sel = kp_entry.deref('password') or "" 82 | elif sel == "TOTP: ******": 83 | sel = gen_otp(get_otp_url(kp_entry)) 84 | elif sel == fields[4] and not keepmenu.CONF.getboolean("database", "type_url", fallback=False): 85 | if sel != "URL: None": 86 | webbrowser.open(sel) 87 | sel = "" 88 | else: 89 | for attr in attrs: 90 | if sel == f'{attr}: {attrs.get(attr) or ""}': 91 | sel = attrs.get(attr) 92 | break 93 | if sel == f'{attr}: ': 94 | sel = view_notes(attrs.get(attr) or "") 95 | 96 | return sel if not sel.endswith(": None") else "" 97 | 98 | 99 | def view_notes(notes): 100 | """View the 'Notes' field line-by-line within dmenu. 101 | 102 | Returns: text of the selected line for typing 103 | 104 | """ 105 | notes_l = notes.split('\n') 106 | sel = dmenu_select(min(keepmenu.MAX_LEN, len(notes_l)), inp=notes) 107 | return sel 108 | 109 | 110 | def generate_prompt(max_length, dbname): 111 | """Generate a prompt in the format "Entries: {}", with "{}" replaced by 112 | the full path to the database truncated to a certain length 113 | 114 | max_length: an int giving the maximum length for the path, or a bool 115 | specifying whether to show the entire path (True) or to hide it (False) 116 | 117 | dbname: the full path to the database 118 | 119 | """ 120 | if max_length is False or max_length == 0: 121 | return "Entries" 122 | if max_length is True or max_length is None: 123 | return f"Entries: {dbname}" 124 | # Truncate the path so that it is no more than max_length 125 | # or the length of the filename, whichever is larger 126 | filename = os.path.basename(dbname) 127 | if len(filename) >= max_length - 3: 128 | return f"Entries: {filename}" 129 | path = dbname.replace(os.path.expanduser("~"), "~") 130 | if len(path) <= max_length: 131 | return f"Entries: {path}" 132 | path = path[:(max_length - len(filename) - 3)] 133 | return f"Entries: {path}...{filename}" 134 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "keepmenu" 7 | dynamic = ["version"] 8 | description = "Dmenu frontend for Keepass databases" 9 | readme = "README.md" 10 | license = "GPL-3.0" 11 | authors = [ 12 | { name = "Scott Hansen", email = "tech@firecat53.net" }, 13 | ] 14 | keywords = [ 15 | "dmenu", 16 | "keepass", 17 | "keepassxc", 18 | "rofi", 19 | "wofi", 20 | ] 21 | classifiers = [ 22 | "Development Status :: 4 - Beta", 23 | "Environment :: Console", 24 | "Environment :: X11 Applications", 25 | "Intended Audience :: End Users/Desktop", 26 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3.7", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Programming Language :: Python :: 3.13", 35 | "Topic :: Utilities", 36 | ] 37 | dependencies = [ 38 | "pykeepass>=4.0.0", 39 | "pynput", 40 | ] 41 | 42 | [project.scripts] 43 | keepmenu = "keepmenu.__main__:main" 44 | 45 | [project.urls] 46 | Homepage = "https://github.com/firecat53/keepmenu" 47 | 48 | [tool.hatch.version] 49 | source = "vcs" 50 | fallback-version = "0.0.0" 51 | 52 | [tool.hatch.version.raw-options] 53 | local_scheme = "no-local-version" 54 | 55 | [tool.hatch.build.hooks.vcs] 56 | version-file = "keepmenu/_version.py" 57 | 58 | [tool.hatch.build.targets.wheel.shared-data] 59 | LICENSE = "share/doc/keepmenu/LICENSE" 60 | "README.md" = "share/doc/keepmenu/README.md" 61 | "config.ini.example" = "share/doc/keepmenu/config.ini.example" 62 | docs = "share/doc/keepmenu/docs" 63 | "keepmenu.1" = "share/man/man1/keepmenu.1" 64 | 65 | [tool.hatch.build.targets.sdist] 66 | include = [ 67 | "/keepmenu", 68 | ] 69 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pykeepass>=4.0.0 2 | pynput 3 | -------------------------------------------------------------------------------- /tests/keepmenu-config.ini: -------------------------------------------------------------------------------- 1 | [dmenu] 2 | dmenu_command = /usr/bin/dmenu -i -l 10 -fn Inconsolata-12 -nb #909090 -nf #999999 -b 3 | pinentry = /usr/bin/pinentry 4 | 5 | [dmenu_passphrase] 6 | obscure = True 7 | obscure_color = #222222 8 | 9 | [database] 10 | database_1 = test.kdbx 11 | password_1 = password 12 | pw_cache_period_min = 10 13 | terminal = urxvt 14 | gui_editor = gvim -f 15 | type_library = xdotool 16 | hide_groups = Recycle Bin 17 | Test 18 | [password_chars] 19 | punc min = !@#$%% 20 | 21 | [password_char_presets] 22 | Minimal Punc = upper lower digits "punc min" 23 | -------------------------------------------------------------------------------- /tests/test.kdbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firecat53/keepmenu/cc35779b3cbc14588ce25fa9b768b1a97f0eae3b/tests/test.kdbx -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | """Unit tests for keepmenu 2 | 3 | """ 4 | from multiprocessing.managers import BaseManager 5 | import os 6 | from shutil import copyfile, rmtree 7 | import socket 8 | import string 9 | import sys 10 | import tempfile 11 | import unittest 12 | from unittest import mock 13 | from pykeepass import PyKeePass 14 | 15 | import keepmenu as KM 16 | from keepmenu import __main__ # noqa: F401 17 | 18 | SECRET1 = 'ZYTYYE5FOAGW5ML7LRWUL4WTZLNJAMZS' 19 | SECRET2 = 'PW4YAYYZVDE5RK2AOLKUATNZIKAFQLZO' 20 | 21 | 22 | class TestServer(unittest.TestCase): 23 | """Test various BaseManager server functions 24 | 25 | """ 26 | def setUp(self): 27 | self.tmpdir = tempfile.mkdtemp() 28 | KM.AUTH_FILE = os.path.join(self.tmpdir, "keepmenu-auth") 29 | 30 | def tearDown(self): 31 | rmtree(self.tmpdir) 32 | 33 | def test_auth(self): 34 | """Test get_auth returns port(int) and key(bytes), and when run a second 35 | time returns those same values from the cache file 36 | 37 | """ 38 | port, key = KM.__main__.get_auth() 39 | self.assertIsInstance(port, int) 40 | if sys.version_info.major < 3: 41 | self.assertIsInstance(key, str) 42 | else: 43 | self.assertIsInstance(key, bytes) 44 | port2, key2 = KM.__main__.get_auth() 45 | self.assertEqual(port2, port) 46 | self.assertEqual(key2, key) 47 | 48 | def test_client_without_server(self): 49 | """Ensure client raises an error with no server running 50 | 51 | """ 52 | self.assertRaises(socket.error, KM.__main__.client, port=1, auth='abcd'.encode(KM.ENC)) 53 | 54 | def test_server(self): 55 | """Ensure BaseManager server starts 56 | 57 | """ 58 | server = KM.__main__.Server() 59 | server.start() 60 | self.assertTrue(server.is_alive()) 61 | server.terminate() 62 | 63 | def test_client_with_server(self): 64 | """Ensure client() function can connect with a BaseManager server 65 | instance 66 | 67 | """ 68 | port, key = KM.__main__.get_auth() 69 | mgr = BaseManager(address=('127.0.0.1', port), authkey=key) 70 | mgr.get_server() 71 | mgr.start() # pylint: disable=consider-using-with 72 | self.assertIsInstance(KM.__main__.client(port, key), BaseManager) 73 | mgr.shutdown() 74 | 75 | def test_pipe_from_client_to_server(self): 76 | """Ensure client can send message to server via a pipe 77 | 78 | """ 79 | 80 | server = KM.__main__.Server() 81 | server.start() 82 | conn = server._get_pipe() # pylint: disable=protected-access 83 | conn.send('test') 84 | self.assertEqual('test', server.get_args()) 85 | server.terminate() 86 | 87 | 88 | class TestFunctions(unittest.TestCase): 89 | """Test the various Keepass functions 90 | 91 | """ 92 | def setUp(self): 93 | self.tmpdir = tempfile.mkdtemp() 94 | KM.CONF_FILE = os.path.join(self.tmpdir, "keepmenu-config.ini") 95 | 96 | def tearDown(self): 97 | rmtree(self.tmpdir) 98 | 99 | def test_config_option(self): 100 | # First test default config 101 | KM.reload_config(os.path.join(self.tmpdir, "config.ini")) 102 | self.assertTrue(KM.menu.dmenu_cmd(10, "Entries") == ["dmenu", "-p", "Entries", "-l", "10"]) 103 | # Test full config 104 | copyfile("tests/keepmenu-config.ini", os.path.join(self.tmpdir, "keepmenu-config.ini")) 105 | KM.reload_config(os.path.join(self.tmpdir, "keepmenu-config.ini")) 106 | self.assertTrue(KM.CONF.get("database", "database_1") == "test.kdbx") 107 | res = ["/usr/bin/dmenu", "-i", "-l", "10", "-fn", "Inconsolata-12", 108 | "-nb", "#909090", "-nf", "#999999", "-b", "-p", "Password", 109 | "-l", "20", "-nb", "#222222", "-nf", "#222222", ] 110 | self.assertTrue(KM.menu.dmenu_cmd(20, "Password") == res) 111 | 112 | def test_get_password_conf(self): 113 | """Test proper reading of password config names with spaces 114 | 115 | """ 116 | copyfile("tests/keepmenu-config.ini", KM.CONF_FILE) 117 | KM.reload_config() 118 | self.assertTrue(KM.CONF.has_section("password_chars")) 119 | self.assertTrue(KM.CONF.has_option("password_chars", "punc min") and 120 | KM.CONF.get("password_chars", "punc min") == "!@#$%") 121 | self.assertTrue(KM.CONF.has_section("password_char_presets")) 122 | self.assertTrue(KM.CONF.has_option("password_char_presets", "Minimal Punc") and 123 | KM.CONF.get("password_char_presets", "Minimal Punc") == 124 | 'upper lower digits "punc min"') 125 | 126 | def test_generate_password(self): 127 | """Test gen_passwd function 128 | 129 | """ 130 | chars = {'Letters': {'upper': string.ascii_uppercase, 131 | 'lower': string.ascii_lowercase}, 132 | 'Min Punc': {'min punc': '!@#$%', 133 | 'digits': string.digits, 134 | 'upper': 'ABCDE'}} 135 | self.assertFalse(KM.edit.gen_passwd({})) 136 | pword = KM.edit.gen_passwd(chars, 10) 137 | self.assertEqual(len(pword), 10) 138 | pword = set(pword) 139 | self.assertFalse(pword.isdisjoint(set('ABCDE'))) 140 | self.assertFalse(pword.isdisjoint(set(string.digits))) 141 | self.assertFalse(pword.isdisjoint(set(string.ascii_lowercase))) 142 | self.assertFalse(pword.isdisjoint(set(string.ascii_uppercase))) 143 | self.assertFalse(pword.isdisjoint(set('!@#$%'))) 144 | self.assertTrue(pword.isdisjoint(set(' '))) 145 | pword = KM.edit.gen_passwd(chars, 3) 146 | pword = KM.edit.gen_passwd(chars, 5) 147 | self.assertEqual(len(pword), 5) 148 | chars = {'Min Punc': {'min punc': '!@#$%', 149 | 'digits': string.digits, 150 | 'upper': 'ABCDE'}} 151 | pword = KM.edit.gen_passwd(chars, 50) 152 | self.assertEqual(len(pword), 50) 153 | pword = set(pword) 154 | self.assertFalse(pword.isdisjoint(set('ABCDE'))) 155 | self.assertFalse(pword.isdisjoint(set(string.digits))) 156 | self.assertFalse(pword.isdisjoint(set('!@#$%'))) 157 | self.assertTrue(pword.isdisjoint(set(string.ascii_lowercase))) 158 | self.assertTrue(pword.isdisjoint(set(' '))) 159 | 160 | def test_conf(self): 161 | """Test generating config file when none exists 162 | 163 | """ 164 | KM.reload_config() 165 | self.assertTrue(KM.CONF.has_section("dmenu")) 166 | self.assertTrue(KM.CONF.has_section("dmenu_passphrase")) 167 | self.assertTrue(KM.CONF.has_option("dmenu_passphrase", "obscure_color") and 168 | KM.CONF.get("dmenu_passphrase", "obscure_color") == "#222222") 169 | self.assertTrue(KM.CONF.has_option("dmenu_passphrase", "obscure") and 170 | KM.CONF.get("dmenu_passphrase", "obscure") == "True") 171 | self.assertTrue(KM.CONF.has_section("database")) 172 | self.assertTrue(KM.CONF.has_option("database", "database_1") and 173 | KM.CONF.get("database", "database_1") == '') 174 | self.assertTrue(KM.CONF.has_option("database", "keyfile_1") and 175 | KM.CONF.get("database", "keyfile_1") == '') 176 | self.assertTrue(KM.CONF.has_option("database", "pw_cache_period_min") and 177 | KM.CONF.get("database", "pw_cache_period_min") == 178 | str(KM.CACHE_PERIOD_DEFAULT_MIN)) 179 | 180 | def test_create_database(self): 181 | """Test database create 182 | 183 | """ 184 | db_name = os.path.join(self.tmpdir, "test.kdbx") 185 | keyfile = os.path.join(self.tmpdir, "keyfile") 186 | with open(keyfile, 'wb') as fout: 187 | fout.write(os.urandom(1024)) 188 | kpo = KM.keepmenu.create_db(db_name, keyfile, 'password') 189 | self.assertIsInstance(kpo, PyKeePass) 190 | self.assertEqual(kpo.filename, db_name) 191 | self.assertEqual(kpo.keyfile, keyfile) 192 | self.assertEqual(kpo.password, "password") 193 | 194 | def test_dmenu_cmd(self): 195 | """Test proper reading of dmenu command string from config.ini 196 | 197 | """ 198 | self.tmpdir = tempfile.mkdtemp() 199 | KM.CONF_FILE = os.path.join(self.tmpdir, "config.ini") 200 | KM.reload_config() 201 | # First test default config 202 | self.assertTrue(KM.menu.dmenu_cmd(10, "Entries") == ["dmenu", "-p", "Entries", "-l", "10"]) 203 | # Test full config 204 | copyfile("tests/keepmenu-config.ini", KM.CONF_FILE) 205 | KM.reload_config() 206 | res = ["/usr/bin/dmenu", "-i", "-l", "10", "-fn", "Inconsolata-12", 207 | "-nb", "#909090", "-nf", "#999999", "-b", "-p", "Password", 208 | "-l", "20", "-nb", "#222222", "-nf", "#222222"] 209 | self.assertTrue(KM.menu.dmenu_cmd(20, "Password") == res) 210 | 211 | def test_generate_prompt(self): 212 | """Test properly generating prompt using various values of max_length 213 | (the title_path option in the config) 214 | 215 | """ 216 | dbname = f"{os.path.expanduser('~')}/docs/passwords.kdbx" 217 | self.assertTrue(KM.view.generate_prompt(True, dbname) == 218 | f"Entries: {dbname}") 219 | self.assertTrue(KM.view.generate_prompt(len(dbname), dbname) == 220 | "Entries: ~/docs/passwords.kdbx") 221 | self.assertTrue(KM.view.generate_prompt(len("~/docs/passwords.kdbx"), 222 | dbname) == 223 | "Entries: ~/docs/passwords.kdbx") 224 | self.assertTrue(KM.view.generate_prompt(20, dbname) == 225 | "Entries: ~/d...passwords.kdbx") 226 | self.assertTrue(KM.view.generate_prompt(18, dbname) == 227 | "Entries: ~...passwords.kdbx") 228 | self.assertTrue(KM.view.generate_prompt(10, dbname) == 229 | "Entries: passwords.kdbx") 230 | self.assertTrue(KM.view.generate_prompt(0, dbname) == 231 | "Entries") 232 | self.assertTrue(KM.view.generate_prompt(False, dbname) == 233 | "Entries") 234 | 235 | def test_get_databases(self): 236 | """Test reading database information from config 237 | 238 | """ 239 | db_name = os.path.join(self.tmpdir, "test.kdbx") 240 | db_name_2 = os.path.join(self.tmpdir, "test2.kdbx") 241 | copyfile("tests/keepmenu-config.ini", KM.CONF_FILE) 242 | KM.reload_config() 243 | with open(KM.CONF_FILE, 'w', encoding=KM.ENC) as conf_file: 244 | KM.CONF.set('database', 'database_1', db_name) 245 | KM.CONF.set('database', 'password_1', '') 246 | KM.CONF.set('database', 'password_cmd_1', 'echo password') 247 | 248 | KM.CONF.set('database', 'database_2', db_name_2) 249 | KM.CONF.set('database', 'autotype_default_2', '{TOTP}{ENTER}') 250 | 251 | KM.CONF.write(conf_file) 252 | 253 | databases = KM.keepmenu.get_databases() 254 | 255 | db1 = KM.keepmenu.DataBase(dbase=db_name, pword='password') 256 | db2 = KM.keepmenu.DataBase(dbase=db_name_2, atype='{TOTP}{ENTER}') 257 | self.assertEqual(db1.__dict__, databases[0].__dict__) 258 | self.assertEqual(db2.__dict__, databases[1].__dict__) 259 | 260 | def test_open_database(self): 261 | """Test database opens properly 262 | 263 | """ 264 | db_name = os.path.join(self.tmpdir, "test.kdbx") 265 | copyfile("tests/test.kdbx", db_name) 266 | copyfile("tests/keepmenu-config.ini", KM.CONF_FILE) 267 | with open(KM.CONF_FILE, 'w', encoding=KM.ENC) as conf_file: 268 | KM.CONF.set('database', 'database_1', db_name) 269 | KM.CONF.write(conf_file) 270 | database, _ = KM.keepmenu.get_database() 271 | database.kpo = None # Can't compare kpo objects 272 | self.assertTrue(database == KM.keepmenu.DataBase(dbase=db_name, pword='password')) 273 | kpo = KM.keepmenu.get_entries(database) 274 | self.assertIsInstance(kpo, PyKeePass) 275 | # Switch from `password_1` to `password_cmd_1` 276 | with open(KM.CONF_FILE, 'w', encoding=KM.ENC) as conf_file: 277 | KM.CONF.set('database', 'password_1', '') 278 | KM.CONF.set('database', 'password_cmd_1', 'echo password') 279 | KM.CONF.write(conf_file) 280 | database, _ = KM.keepmenu.get_database() 281 | database.kpo = None # Can't compare kpo objects 282 | self.assertTrue(database == KM.keepmenu.DataBase(dbase=db_name, pword='password')) 283 | kpo = KM.keepmenu.get_entries(database) 284 | self.assertIsInstance(kpo, PyKeePass) 285 | with open(KM.CONF_FILE, 'w', encoding=KM.ENC) as conf_file: 286 | KM.CONF.set('database', 'autotype_default_1', '{TOTP}{ENTER}') 287 | KM.CONF.write(conf_file) 288 | database, _ = KM.keepmenu.get_database() 289 | database.kpo = None # Can't compare kpo objects 290 | self.assertTrue(database == KM.keepmenu.DataBase(dbase=db_name, 291 | pword='password', 292 | atype='{TOTP}{ENTER}')) 293 | 294 | database, _ = KM.keepmenu.get_database(database=db_name) 295 | self.assertIsInstance(database.kpo, PyKeePass) 296 | database.kpo = None # Can't compare DataBase objects with another object in them 297 | self.assertTrue(database == KM.keepmenu.DataBase(dbase=db_name, 298 | pword='password', 299 | atype='{TOTP}{ENTER}')) 300 | 301 | def test_resolve_references(self): 302 | """Test keepass references can be resolved to values 303 | 304 | """ 305 | db_name = os.path.join(self.tmpdir, "test.kdbx") 306 | copyfile("tests/test.kdbx", db_name) 307 | copyfile("tests/keepmenu-config.ini", KM.CONF_FILE) 308 | with open(KM.CONF_FILE, 'w', encoding=KM.ENC) as conf_file: 309 | KM.CONF.set('database', 'database_1', db_name) 310 | KM.CONF.write(conf_file) 311 | database, _ = KM.keepmenu.get_database() 312 | kpo = KM.keepmenu.get_entries(database) 313 | ref_entry = kpo.find_entries_by_title(title='.*REF.*', regex=True)[0] 314 | base_entry = kpo.find_entries_by_title(title='Test Title 1')[0] 315 | self.assertEqual(ref_entry.deref("title"), "Reference Entry Test - " + base_entry.title) 316 | self.assertEqual(ref_entry.deref("username"), base_entry.username) 317 | self.assertEqual(ref_entry.deref("password"), base_entry.password) 318 | self.assertEqual(ref_entry.deref("url"), base_entry.url) 319 | self.assertEqual(ref_entry.deref("notes"), base_entry.notes) 320 | 321 | def test_additional_attributes(self): 322 | """Test if additional attributes are correctly accessed 323 | 324 | """ 325 | db_name = os.path.join(self.tmpdir, "test.kdbx") 326 | copyfile("tests/test.kdbx", db_name) 327 | copyfile("tests/keepmenu-config.ini", KM.CONF_FILE) 328 | KM.reload_config() 329 | with open(KM.CONF_FILE, 'w', encoding=KM.ENC) as conf_file: 330 | KM.CONF.set('database', 'database_1', db_name) 331 | KM.CONF.set('database', 'password_1', "password") 332 | KM.CONF.write(conf_file) 333 | 334 | database, _ = KM.keepmenu.get_database(database=db_name) 335 | kpo = KM.keepmenu.get_entries(database) 336 | entry = kpo.find_entries_by_title(title='Additional Attributes')[0] 337 | 338 | self.assertEqual(KM.type.token_command('{S:Attr 1}')(entry), "one") 339 | self.assertEqual(KM.type.token_command('{S:Attr 2}')(entry), "two") 340 | 341 | def test_expiry(self): 342 | """Test expiring/expired entries can be found 343 | 344 | """ 345 | db_name = os.path.join(self.tmpdir, "test.kdbx") 346 | copyfile("tests/test.kdbx", db_name) 347 | copyfile("tests/keepmenu-config.ini", KM.CONF_FILE) 348 | with open(KM.CONF_FILE, 'w', encoding=KM.ENC) as conf_file: 349 | KM.CONF.set('database', 'database_1', db_name) 350 | KM.CONF.write(conf_file) 351 | database, _ = KM.keepmenu.get_database() 352 | kpo = KM.keepmenu.get_entries(database) 353 | expiring_entries = KM.keepmenu.get_expiring_entries(kpo.entries) 354 | self.assertEqual(len(expiring_entries), 1) 355 | 356 | def test_tokenize_autotype(self): 357 | """Test tokenizing autotype strings 358 | """ 359 | tokens = list(KM.type.tokenize_autotype("blah{SOMETHING}")) 360 | self.assertEqual(len(tokens), 2) 361 | self.assertEqual(tokens[0], ("blah", False)) 362 | self.assertEqual(tokens[1], ("{SOMETHING}", True)) 363 | 364 | tokens = list(KM.type.tokenize_autotype("/abc{USERNAME}{ENTER}{TAB}{TAB} {SOMETHING}")) 365 | self.assertEqual(len(tokens), 7) 366 | self.assertEqual(tokens[0], ("/abc", False)) 367 | self.assertEqual(tokens[1], ("{USERNAME}", True)) 368 | self.assertEqual(tokens[4], ("{TAB}", True)) 369 | self.assertEqual(tokens[5], (" ", False)) 370 | self.assertEqual(tokens[6], ("{SOMETHING}", True)) 371 | 372 | tokens = list(KM.type.tokenize_autotype("?{}}blah{{}{}}")) 373 | self.assertEqual(len(tokens), 5) 374 | self.assertEqual(tokens[0], ("?", False)) 375 | self.assertEqual(tokens[1], ("{}}", True)) 376 | self.assertEqual(tokens[2], ("blah", False)) 377 | self.assertEqual(tokens[3], ("{{}", True)) 378 | self.assertEqual(tokens[4], ("{}}", True)) 379 | 380 | tokens = list(KM.type.tokenize_autotype("{DELAY 5}b{DELAY=50}")) 381 | self.assertEqual(len(tokens), 3) 382 | self.assertEqual(tokens[0], ("{DELAY 5}", True)) 383 | self.assertEqual(tokens[1], ("b", False)) 384 | self.assertEqual(tokens[2], ("{DELAY=50}", True)) 385 | 386 | tokens = list(KM.type.tokenize_autotype("+{DELAY 5}plus^carat~@{}}")) 387 | self.assertEqual(len(tokens), 8) 388 | self.assertEqual(tokens[0], ("+", True)) 389 | self.assertEqual(tokens[1], ("{DELAY 5}", True)) 390 | self.assertEqual(tokens[2], ("plus", False)) 391 | self.assertEqual(tokens[3], ("^", True)) 392 | self.assertEqual(tokens[4], ("carat", False)) 393 | self.assertEqual(tokens[5], ("~", True)) 394 | self.assertEqual(tokens[6], ("@", True)) 395 | self.assertEqual(tokens[7], ("{}}", True)) 396 | 397 | def test_token_command(self): 398 | """ test the token command 399 | """ 400 | self.assertTrue(callable(KM.type.token_command('{DELAY 5}'))) 401 | self.assertFalse(callable(KM.type.token_command('{DELAY 5 }'))) 402 | self.assertFalse(callable(KM.type.token_command('{DELAY 5'))) 403 | self.assertFalse(callable(KM.type.token_command('{DELAY a }'))) 404 | self.assertFalse(callable(KM.type.token_command('{DELAY }'))) 405 | self.assertFalse(callable(KM.type.token_command('{DELAY}'))) 406 | self.assertFalse(callable(KM.type.token_command('DELAY 5}'))) 407 | self.assertFalse(callable(KM.type.token_command('{DELAY a}'))) 408 | 409 | self.assertTrue(callable(KM.type.token_command('{S:a}'))) 410 | self.assertTrue(callable(KM.type.token_command('{S: a}'))) 411 | self.assertTrue(callable(KM.type.token_command('{S: a }'))) 412 | self.assertFalse(callable(KM.type.token_command('S: a}'))) 413 | 414 | def test_hotp(self): 415 | """ adapted from https://github.com/susam/mintotp/blob/master/test.py 416 | """ 417 | self.assertEqual(KM.totp.hotp(SECRET1, 0), '549419') 418 | self.assertEqual(KM.totp.hotp(SECRET2, 0), '009551') 419 | self.assertEqual(KM.totp.hotp(SECRET1, 0, 5, 'sha1', True), '9XFQT') 420 | self.assertEqual(KM.totp.hotp(SECRET2, 0, 5, 'sha1', True), 'QR5CX') 421 | self.assertEqual(KM.totp.hotp(SECRET1, 42), '626854') 422 | self.assertEqual(KM.totp.hotp(SECRET2, 42), '093610') 423 | self.assertEqual(KM.totp.hotp(SECRET1, 42, 5, 'sha1', True), '25256') 424 | self.assertEqual(KM.totp.hotp(SECRET2, 42, 5, 'sha1', True), 'RHH8D') 425 | 426 | def test_totp(self): 427 | """ adapted from https://github.com/susam/mintotp/blob/master/test.py 428 | """ 429 | with mock.patch('time.time', return_value=0): 430 | self.assertEqual(KM.totp.totp(SECRET1), '549419') 431 | self.assertEqual(KM.totp.totp(SECRET2), '009551') 432 | self.assertEqual(KM.totp.totp(SECRET1, 30, 5, 'sha1', True), '9XFQT') 433 | self.assertEqual(KM.totp.totp(SECRET2, 30, 5, 'sha1', True), 'QR5CX') 434 | with mock.patch('time.time', return_value=10): 435 | self.assertEqual(KM.totp.totp(SECRET1), '549419') 436 | self.assertEqual(KM.totp.totp(SECRET2), '009551') 437 | self.assertEqual(KM.totp.totp(SECRET1, 30, 5, 'sha1', True), '9XFQT') 438 | self.assertEqual(KM.totp.totp(SECRET2, 30, 5, 'sha1', True), 'QR5CX') 439 | with mock.patch('time.time', return_value=1260): 440 | self.assertEqual(KM.totp.totp(SECRET1), '626854') 441 | self.assertEqual(KM.totp.totp(SECRET2), '093610') 442 | self.assertEqual(KM.totp.totp(SECRET1, 30, 5, 'sha1', True), '25256') 443 | self.assertEqual(KM.totp.totp(SECRET2, 30, 5, 'sha1', True), 'RHH8D') 444 | with mock.patch('time.time', return_value=1270): 445 | self.assertEqual(KM.totp.totp(SECRET1), '626854') 446 | self.assertEqual(KM.totp.totp(SECRET2), '093610') 447 | self.assertEqual(KM.totp.totp(SECRET1, 30, 5, 'sha1', True), '25256') 448 | self.assertEqual(KM.totp.totp(SECRET2, 30, 5, 'sha1', True), 'RHH8D') 449 | 450 | def test_gen_otp(self): 451 | """ Test OTP generation 452 | """ 453 | otp_url_1 = "otpauth://totp/test:none?secret={secret}&period={period}&digits={digits}" 454 | otp_url_2 = "key={secret}&step={period}&size={digits}" 455 | for otp_url in [otp_url_1, otp_url_2]: 456 | with mock.patch('time.time', return_value=0): 457 | self.assertEqual(KM.totp.gen_otp(otp_url.format( 458 | secret=SECRET1, 459 | period=30, 460 | digits=6 461 | )), '549419') 462 | self.assertEqual(KM.totp.gen_otp(otp_url.format( 463 | secret=SECRET2, 464 | period=30, 465 | digits=6 466 | )), '009551') 467 | 468 | with mock.patch('time.time', return_value=1260): 469 | self.assertEqual(KM.totp.gen_otp(otp_url.format( 470 | secret=SECRET1, 471 | period=30, 472 | digits=6 473 | )), '626854') 474 | self.assertEqual(KM.totp.gen_otp(otp_url.format( 475 | secret=SECRET2, 476 | period=30, 477 | digits=6 478 | )), '093610') 479 | 480 | with mock.patch('time.time', return_value=1270): 481 | self.assertEqual(KM.totp.gen_otp(otp_url.format( 482 | secret=SECRET1, 483 | period=30, 484 | digits=6 485 | )), '626854') 486 | self.assertEqual(KM.totp.gen_otp(otp_url.format( 487 | secret=SECRET2, 488 | period=30, 489 | digits=6 490 | )), '093610') 491 | 492 | # keeotp's otp field empirically doesn't support steam encoding 493 | for otp_url in [otp_url_1]: 494 | with mock.patch('time.time', return_value=0): 495 | self.assertEqual(KM.totp.gen_otp(otp_url.format( 496 | secret=SECRET1, 497 | period=30, 498 | digits=5 499 | ) + "&encoder=steam"), '9XFQT') 500 | self.assertEqual(KM.totp.gen_otp(otp_url.format( 501 | secret=SECRET2, 502 | period=30, 503 | digits=5 504 | ) + "&encoder=steam"), 'QR5CX') 505 | 506 | with mock.patch('time.time', return_value=1260): 507 | self.assertEqual(KM.totp.gen_otp(otp_url.format( 508 | secret=SECRET1, 509 | period=30, 510 | digits=5 511 | ) + "&encoder=steam"), '25256') 512 | self.assertEqual(KM.totp.gen_otp(otp_url.format( 513 | secret=SECRET2, 514 | period=30, 515 | digits=5 516 | ) + "&encoder=steam"), 'RHH8D') 517 | 518 | with mock.patch('time.time', return_value=1270): 519 | self.assertEqual(KM.totp.gen_otp(otp_url.format( 520 | secret=SECRET1, 521 | period=30, 522 | digits=5 523 | ) + "&encoder=steam"), '25256') 524 | self.assertEqual(KM.totp.gen_otp(otp_url.format( 525 | secret=SECRET2, 526 | period=30, 527 | digits=5 528 | ) + "&encoder=steam"), 'RHH8D') 529 | 530 | def test_entry_otp(self): 531 | """Test OTP generation from kdbx entries 532 | """ 533 | db_name = os.path.join(self.tmpdir, "test.kdbx") 534 | copyfile("tests/test.kdbx", db_name) 535 | copyfile("tests/keepmenu-config.ini", KM.CONF_FILE) 536 | with open(KM.CONF_FILE, 'w', encoding=KM.ENC) as conf_file: 537 | KM.CONF.set('database', 'database_1', db_name) 538 | KM.CONF.write(conf_file) 539 | database = KM.keepmenu.DataBase(dbase=db_name, pword='password') 540 | kpo = KM.keepmenu.get_entries(database) 541 | # entry with otpsecret=SECRET1 in keepass2 fieldset 542 | kp2_entry = kpo.find_entries_by_title(title='keepass2 totp')[0] 543 | # entry with otpsecret=SECRET2, size=8, period=60 in keepass2 fieldset 544 | kp2_more_entry = kpo.find_entries_by_title(title='keepass2 totp - more fields')[0] 545 | # entry with otpsecret=SECRET1 as keepass2 fieldset and SECRET2 in otp field which comes first 546 | kp2_multi_entry = kpo.find_entries_by_title(title='keepass2 totp - multiple configs')[0] 547 | with mock.patch('time.time', return_value=0): 548 | self.assertEqual(KM.totp.gen_otp(KM.totp.get_otp_url(kp2_entry)), "549419") 549 | self.assertEqual(KM.totp.gen_otp(KM.totp.get_otp_url(kp2_more_entry)), "04607023") 550 | self.assertEqual(KM.totp.gen_otp(KM.totp.get_otp_url(kp2_multi_entry)), "009551") 551 | with mock.patch('time.time', return_value=1260): 552 | self.assertEqual(KM.totp.gen_otp(KM.totp.get_otp_url(kp2_entry)), "626854") 553 | self.assertEqual(KM.totp.gen_otp(KM.totp.get_otp_url(kp2_more_entry)), "59008166") 554 | self.assertEqual(KM.totp.gen_otp(KM.totp.get_otp_url(kp2_multi_entry)), "093610") 555 | with mock.patch('time.time', return_value=1270): 556 | self.assertEqual(KM.totp.gen_otp(KM.totp.get_otp_url(kp2_entry)), "626854") 557 | self.assertEqual(KM.totp.gen_otp(KM.totp.get_otp_url(kp2_more_entry)), "59008166") 558 | self.assertEqual(KM.totp.gen_otp(KM.totp.get_otp_url(kp2_multi_entry)), "093610") 559 | 560 | 561 | if __name__ == "__main__": 562 | unittest.main() 563 | --------------------------------------------------------------------------------