diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..3c78a08 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,53 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +env: + GO_VERSION: '^1.18.3' + +jobs: + license-check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: License check + run: | + make license_check + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Lint + run: | + make lint + + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v3 + with: + go-version: ${{ env.GO_VERSION }} + - name: Tests + coverage + run: | + make coverage + + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v3 + with: + go-version: ${{ env.GO_VERSION }} + - name: Build (all platforms) + run: | + make build_all_platforms diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..de3c268 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-go@v3 + with: + go-version: '^1.18.3' + + - name: Build for all platforms + run: | + make build_all_platforms + + - name: Upload build artifacts + uses: actions/upload-artifact@v3 + with: + name: pasuman-build-artifacts + path: pasuman-* + + create_release: + runs-on: ubuntu-20.04 + + needs: build + + outputs: + release_upload_url: ${{ steps.create_release.outputs.upload_url }} + + steps: + - name: Create release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: ${{ github.ref }} + + upload_release_assets: + runs-on: ubuntu-20.04 + + needs: create_release + + strategy: + matrix: + output: [linux-amd64, darwin-amd64, darwin-arm64] + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v3 + with: + name: pasuman-build-artifacts + + - name: Upload release assets + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create_release.outputs.release_upload_url }} + asset_path: ./pasuman-${{ matrix.output }} + asset_name: pasuman-${{ matrix.output }} + asset_content_type: application/octet-stream diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb98253 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +coverage.out +pasuman* +!pasumantest/ \ No newline at end of file diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..f9759ac --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,32 @@ +# unfortunately, go 1.18 does not support all linters for now, +# so we run the linter for go 1.17 +# see https://github.com/golangci/golangci-lint/issues/2649 +run: + go: '1.17' + +linters: + enable-all: true + disable: + - cyclop + - exhaustruct + - exhaustivestruct + - forbidigo + - gochecknoglobals + - paralleltest + - testpackage + - varnamelen + - wrapcheck + +linters-settings: + tagliatelle: + case: + use-field-name: true + rules: + json: snake + +issues: + exclude-rules: + - path: _test\.go + linters: + - funlen + \ No newline at end of file diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ba18107 --- /dev/null +++ b/Makefile @@ -0,0 +1,70 @@ +GIT_TAG = $(shell git describe --tags 2>/dev/null) +GIT_COMMIT = $(shell git rev-parse --short HEAD) +GIT_TREE_STATE = $(shell (git status --porcelain | grep -q .) && echo "-dirty" || echo "") + +EXECUTABLE_OUTPUT = pasuman + +.PHONY: build +build: + @CGO_ENABLED=0 go build -ldflags " \ + -X github.com/norbjd/pasuman/cmd.GitVersion=$(GIT_TAG) \ + -X github.com/norbjd/pasuman/cmd.GitCommit=$(GIT_COMMIT)$(GIT_TREE_STATE) \ + -X github.com/norbjd/pasuman/cmd.BuildDate=`date --utc +%Y-%m-%dT%H:%M:%SZ`" \ + -o $(EXECUTABLE_OUTPUT) + +.PHONY: build_all_platforms +build_all_platforms: + $(MAKE) build GOOS=linux GOARCH=amd64 EXECUTABLE_OUTPUT=pasuman-linux-amd64 + $(MAKE) build GOOS=darwin GOARCH=amd64 EXECUTABLE_OUTPUT=pasuman-darwin-amd64 + $(MAKE) build GOOS=darwin GOARCH=arm64 EXECUTABLE_OUTPUT=pasuman-darwin-arm64 + +.PHONY: test +test: + @go test -coverprofile=coverage.out ./... + +.PHONY: coverage +coverage: test + @go tool cover -func=coverage.out + +.PHONY: lint +lint: + @docker run --rm -v `pwd`:/app -w /app golangci/golangci-lint:v1.46.2 \ + golangci-lint run -v + +.PHONY: gofumpt +gofumpt: + @docker run --rm -v `pwd`:/app -w /app golang:1.18-alpine \ + sh -c 'go install mvdan.cc/gofumpt@latest && gofumpt -l -w .' + +.PHONY: license_fix +license_fix: + @docker run --rm -v `pwd`:/app -w /app golang:1.18-alpine \ + sh -c 'apk add git && \ + go install github.com/apache/skywalking-eyes/cmd/license-eye@v0.3.0 && \ + license-eye -c skywalking-eyes-config.yaml header fix' + +.PHONY: license_check +license_check: + @docker run --rm -v `pwd`:/app -w /app golang:1.18-alpine \ + sh -c 'apk add git && \ + go install github.com/apache/skywalking-eyes/cmd/license-eye@v0.3.0 && \ + license-eye -c skywalking-eyes-config.yaml header check && \ + license-eye -c skywalking-eyes-config.yaml dep check' + +# count lines of Go code (excluding tests) +# remove all import lines +# remove all blank lines +# remove all comment lines +# remove all lines before first ")" (end of imports) +.PHONY: sloc +sloc: + @find . -name '*.go' ! -name '*_test.go' | \ + grep -v 'pasumantest' | \ + xargs -I file awk ' \ + BEGIN { start=0; } \ + /^import/ { next; } \ + /^\s*\/\// { next; } \ + /^$$/ { next; } \ + /^)$$/ { start=1; next; } \ + { if (start==1) print; }' file | \ + wc -l diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fd1789 --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# pasuman: a command-line password manager + +![](./assets/banner.svg) + +![CI](https://github.com/norbjd/pasuman/actions/workflows/main.yml/badge.svg?branch=main) + +pasuman is a command-line password manager designed to be: + +- easy to use +- secure +- lightweight +- small: + - minimum lines of code so it is easy to review + - minimal number of dependencies +- cross-platform (GNU/Linux, MacOS) + +## πŸ“Ή Demo (in 3 minutes!) + +https://user-images.githubusercontent.com/26850303/178991840-25686d44-de03-4d38-be79-e9c82e3230be.mp4 + +## πŸƒ Getting started + +Download latest pasuman from [releases page](https://github.com/norbjd/pasuman/releases). For example, from a Linux machine, run: + +```shell +VERSION=v1.0.0 +curl -Lo pasuman https://github.com/norbjd/pasuman/releases/download/$VERSION/pasuman-linux-amd64 +chmod u+x pasuman +mv pasuman /usr/local/bin +``` + +From a MacOS machine, replace `pasuman-linux-amd64` with the right OS and architecture. + +On first use, run `pasuman master-password` to set a strong master password (see [Security > Choosing a strong master password](#choosing-a-strong-master-password)). + +When asked to enter data directory: + +``` +Enter data directory (leave empty for default: /home/norbjd/.pasuman) +``` + +Choose a directory where your passwords (encrypted) will be stored. Leave default if you don't know, otherwise you can for example set the path to a USB key (`/run/media/norbjd/my-key/.pasuman`) that you can use if you have multiple devices and want to share your passwords between these devices. + +Then, choose a **strong** master password. You will always be able to change it by running again the command `pasuman master-password`. + +You can now use pasuman to add entries, search them, etc. See the demo video above for a preview of what you can do with pasuman. + +You can also enable autocompletion for a better experience, for example by adding `<(source pasuman completion bash)` in your `.bashrc` if you are using `bash` (same command exists for `fish`, `powershell`, and `zsh`). + +## ℹ️ Help + +``` +Copyright (C) 2022 norbjd +License GPLv3: GNU GPL version 3 +This is free software: you are free to change and redistribute it. +There is NO WARRANTY, to the extent permitted by law. + +A command-line password manager + +Usage: + pasuman [command] + +Available Commands: + add Add an entry + completion Generate the autocompletion script for the specified shell + generate Generate a random password + get Get an entry + help Help about any command + list List entries + list-profiles List different profiles + man Generate man pages + master-password Set or change master password + remove Remove an entry + remove-lock Remove lock + search Search an entry by a term + update Update an entry + +Flags: + -h, --help help for pasuman + --profile string Profile (default "default") + -v, --version version for pasuman +``` + +## πŸ’‘ Model + +pasuman stores entries. + +An entry is composed of: + +- an unique ID: if unspecified, a UUID v4 is generated +- a description (optional) +- some tags (optional): to help classify entries +- a site (optional) +- an ID (optional) +- a password + +Only IDs and passwords are considered sensitive data. + +## πŸ’½ Storage + +All entries are stored on disk, in simple JSON file(s). Sensitive data is stored securely (see [Security > ID and password storage](#id-and-password-storage)). + +```json +{ + "master_password": "$argon2id$v=19$m=262144,t=16,p=4$YvSoJz5jjGQWiflI0pP1bW4R+b/FmMOYoypEp8eHHaKeasv2ikt/PpQQUrOXyFB0uKiHOUEc6gSG9SyqtqFTfw$AG/SFTkMBycYb7R0Q0b/me31G2EmAvoa8i7vRgAFI+k", + "entries": [ + { + "unique_id": "8a1c5d7f-8c6c-4d7f-a1a8-756e357d2d5e", + "description": "This is a description", + "tags": [ + "tag1", + "tag2" + ], + "site": "https://mysupersite.pasuman", + "id": "6RydipYrXpV+UdLms37CxfgEt9wl+IE17Tt9TmMjtzR3eGNe8eE3spohg5wmdjCIffqzRFLb0LIjc0z30UjYQA==*sH59oYEASE97QTTL*WhrPBXkxA1T8Q6d5...CPqo", + "password": "DfHhyZ1NFdGILKgtdKFFjK8r2xSrC73vhWWhCih/i0jL2pLe9qGUFDD3tPHGzx4lanIYfY86JkZ9+ClYc9BpQg==*g4HIQQqaJRkaWyK3*POIpZyhDKphfZ4Vq...BJK8" + } + ] +} +``` + +## πŸ”’ Security + +### Master password + +Master password is hashed using [Argon2id](https://www.ietf.org/rfc/rfc9106.html), using a random salt. Parameters used can be seen [in the code](pkg/masterpassword/master_password.go). + +The unhashed master password stays in memory only during the `pasuman` process life. + +### ID and password storage + +Encryption of an ID or a password is done in two steps: + +- derive a key from the master password using [Argon2id](https://www.ietf.org/rfc/rfc9106.html) and a random salt. Parameters used can be seen [in the code](pkg/encrypt/encrypt.go) +- use AES with Galois/Counter Mode (AES-GCM) to encrypt the ID or password using the previously generated key + +The unencrypted ID or password stays in memory only during the `pasuman` process life. + +### Choosing a strong master password + +[EFF Dice-Generated Passphrases](https://www.eff.org/dice) can be used as strong master passwords. Just be sure that you can remember your master password; otherwise, access to all your passwords stored by pasuman will be lost forever. + +### Report a security issue + +To report a critical security or vulnerability issue, contact me at `+pasuman googlemail com`. + +## ❓ FAQ + +**Q**: Why did I get ``Error: file is locked: use `pasuman remove-lock` command to remove it (only if no other pasuman process is running!)``? + +**A**: This can happen: +- after you quit pasuman brutally (example: CTRL+C during the password prompt) +- when another pasuman process is running +- if pasuman exits brutally (for example if it panics; this should not happen, please open [an issue](https://github.com/norbjd/pasuman/issues) if so) + +A lock have been implemented to avoid concurrent executions of pasuman, that can lead to data loss or an unexpected state. + +To solve the issue, check that no other pasuman process is running, and run `pasuman remove-lock`. + +## βœ‰οΈ Contact + +If you have any question, feel free to create [a new discussion](https://github.com/norbjd/pasuman/discussions) or [an issue](https://github.com/norbjd/pasuman/issues) if you noticed a bug. You can also [reach me on Twitter (@norbjd)](https://twitter.com/norbjd). + +## βš–οΈ License + +pasuman is licensed under the GNU General Public License (version 3), see [COPYING](COPYING). + +``` +Copyright (C) 2022 norbjd + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +``` + +**Note**: `pasuman --help` and `pasuman --version` shows a copyright notice, as defined by [GNU Coding Standards](https://www.gnu.org/prep/standards/standards.html#g_t_002d_002dversion). diff --git a/assets/banner.svg b/assets/banner.svg new file mode 100644 index 0000000..f2fb6a6 --- /dev/null +++ b/assets/banner.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + γƒ‘γ‚Ήγƒžγƒ³ + + diff --git a/assets/demo.cast b/assets/demo.cast new file mode 100644 index 0000000..bc90b5c --- /dev/null +++ b/assets/demo.cast @@ -0,0 +1,849 @@ +{"version":2,"width":105,"height":20,"timestamp":1657756800} +[0.00569,"o","\u001b[?2004h[norbjd@demo ~]$ "] +[0.9420502159999999,"o","\u001b[0m# pasuman is a simple command-line password manager\u001b"] +[1.3694814299999998,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# pasuman is a simple command-line password manager\r\n\u001b[?2004l\r"] +[1.3696753019999999,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[3.8554980469999993,"o","\u001b[0m# first, initialize your master password\u001b"] +[4.5014414689999995,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# first, initialize your master password\r\n\u001b[?2004l\r"] +[4.501507246999999,"o","\u001b]0;@demo:~\u0007\u001b[?2004h"] +[4.50155283,"o","[norbjd@demo ~]$ "] +[5.165776188999999,"o","p"] +[5.206594899999999,"o","a"] +[5.2457593519999985,"o","s"] +[5.326185073999999,"o","u"] +[5.440675721999999,"o","m"] +[5.4968582119999985,"o","a"] +[5.552727967999998,"o","n"] +[5.630979553999998,"o"," "] +[5.746063357999998,"o","m"] +[5.7691081609999975,"o","a"] +[5.863399192999998,"o","s"] +[5.948438606999998,"o","t"] +[5.998365262999999,"o","e"] +[6.061954701999999,"o","r"] +[6.197210425999999,"o","-"] +[6.3828232479999985,"o","p"] +[6.424520729999998,"o","a"] +[6.460826146999998,"o","s"] +[6.558200089999998,"o","s"] +[6.7168843219999985,"o","w"] +[6.802848089999998,"o","o"] +[6.9123719219999975,"o","r"] +[7.013837948999997,"o","d"] +[7.384228096999997,"o","\r\n\u001b[?2004l\r"] +[7.385364209999997,"o","Enter data directory (leave empty for default: /home/norbjd/.pasuman): "] +[7.973605900999996,"o","\r\n"] +[7.973716107999997,"o","Enter new master password: "] +[8.798137746999997,"o","βœ”\r\nEnter new master password again: "] +[9.449698263999997,"o","βœ”\r\n"] +[9.915574987999998,"o","Master password has been set!\r\n"] +[9.922599385999996,"o","\u001b]0;@demo:~\u0007\u001b[?2004h"] +[9.922614387999998,"o","[norbjd@demo ~]$ "] +[12.271792569999995,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[12.537767643999997,"o","\u001b[0m# once initialized, you can add new entries with an interactive prompt\u001b"] +[13.069818189999996,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# once initialized, you can add new entries with an interactive prompt\r\n\u001b[?2004l\r"] +[13.069978595999997,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[13.924596952999995,"o","p"] +[13.980392275999995,"o","a"] +[14.028527923999995,"o","s"] +[14.122924546999997,"o","u"] +[14.263031109999995,"o","m"] +[14.366611841999994,"o","a"] +[14.543895668999994,"o","n"] +[14.648274391999994,"o"," "] +[14.786016985999993,"o","a"] +[14.882824891999993,"o","d"] +[14.970978951999994,"o","d"] +[15.289225032999994,"o","\r\n\u001b[?2004l\r"] +[15.291511106999994,"o","Enter current master password: "] +[16.808866470999995,"o","βœ”\r\nEnter unique id (leave empty to generate a random one): "] +[17.579558446999993,"o","m"] +[17.618608075999994,"o","a"] +[17.745950244999992,"o","i"] +[17.85708736899999,"o","l"] +[18.16488109499999,"o","\r\n"] +[18.165051309999992,"o","Enter description: "] +[18.49495509899999,"o","M"] +[18.67993899099999,"o","y"] +[18.82367776899999,"o"," "] +[18.94841766799999,"o","p"] +[18.97763060099999,"o","e"] +[19.019446944999988,"o","r"] +[19.110198081999986,"o","s"] +[19.159372906999987,"o","o"] +[19.280821020999987,"o","n"] +[19.34679923999999,"o","a"] +[19.402213742999987,"o","l"] +[19.50855772799999,"o"," "] +[19.61455955199999,"o","m"] +[19.656356277999986,"o","a"] +[19.742941474999988,"o","i"] +[19.849728595999988,"o","l"] +[20.025100821999988,"o","\r\nEnter tags (comma-separated): "] +[20.345005200999985,"o","m"] +[20.425257822999985,"o","a"] +[20.516768868999982,"o","i"] +[20.686853466999985,"o","l"] +[21.684472618999983,"o",","] +[21.857137560999984,"o","p"] +[21.88016620799998,"o","e"] +[21.925754977999983,"o","r"] +[22.009247454999983,"o","s"] +[22.053138690999983,"o","o"] +[22.289849478999983,"o","\r\nEnter site: "] +[22.567292427999984,"o","h"] +[22.672320852999984,"o","t"] +[22.762571730999984,"o","t"] +[22.833270963999983,"o","p"] +[22.937363494999982,"o","s"] +[23.117414613999983,"o",":"] +[23.282036752999986,"o","/"] +[23.357435650999985,"o","/"] +[23.589721425999983,"o","g"] +[23.628696621999985,"o","m"] +[23.719941670999987,"o","a"] +[23.785506180999985,"o","i"] +[23.895779535999985,"o","l"] +[24.045870506999986,"o","."] +[24.169855689999984,"o","c"] +[24.218527947999988,"o","o"] +[24.323027840999988,"o","m"] +[24.659405569999986,"o","\r\nEnter id: "] +[24.875740179999987,"o","d"] +[24.905305082999988,"o","e"] +[25.535783750999986,"o","m"] +[25.631683458999987,"o","o"] +[25.85041261899999,"o","@"] +[25.984022161999988,"o","g"] +[26.050163094999988,"o","m"] +[26.12602128499999,"o","a"] +[26.18830324199999,"o","i"] +[26.288908384999992,"o","l"] +[26.46304756199999,"o","."] +[26.56074924099999,"o","c"] +[26.61976826299999,"o","o"] +[26.722648516999993,"o","m"] +[27.038882021999992,"o","\r\nEnter password: "] +[28.85156387299999,"o","βœ”\r\n"] +[29.750746247999988,"o","New entry: mail\r\n"] +[29.76072777099999,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[30.855404092999986,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[32.49502979599999,"o","\u001b[0m# you can use get to retrieve the entry by its unique id\u001b"] +[32.77466419099999,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# you can use get to retrieve the entry by its unique id\r\n\u001b[?2004l\r"] +[32.77473516199999,"o","\u001b]0;@demo:~\u0007"] +[32.774767473999994,"o","\u001b[?2004h[norbjd@demo ~]$ "] +[34.275750462999994,"o","\u001b[0m# by default, only non-sensitive data is displayed\u001b"] +[34.72553985,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# by default, only non-sensitive data is displayed\r\n\u001b[?2004l\r"] +[34.725706026,"o","\u001b]0;@demo:~\u0007\u001b[?2004h"] +[34.72584854499999,"o","[norbjd@demo ~]$ "] +[35.21732041199999,"o","p"] +[35.26103393199999,"o","a"] +[35.300130874999994,"o","s"] +[35.407138986999996,"o","u"] +[35.534901788999996,"o","m"] +[35.59891013,"o","a"] +[35.673517961,"o","n"] +[35.772617711,"o"," "] +[35.852476242,"o","g"] +[35.886605215,"o","e"] +[35.984311510000005,"o","t"] +[36.019850094000006,"o"," "] +[36.252263963000004,"o","m"] +[36.341473356,"o","a"] +[36.415687673,"o","i"] +[36.524984744,"o","l"] +[36.799673095,"o","\r\n\u001b[?2004l\r"] +[36.803940587,"o","Unique ID\tDescription\tTags\t\tSite\t\t\t\r\n---------\t-----------\t----\t\t----\t\t\t\r\nmail\t\tMy personal mailmail,perso\thttps://gmail.com\t\r\n"] +[36.804598944,"o","\u001b]0;@demo:~\u0007"] +[36.804746079,"o","\u001b[?2004h[norbjd@demo ~]$ "] +[38.299797508,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[39.728079650999994,"o","\u001b[0m# you can generate random passwords\u001b"] +[40.119167366,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# you can generate random passwords\r\n\u001b[?2004l\r"] +[40.119346236,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[41.751871954,"o","\u001b[0m# by default, length is 128 characters but you can change it\u001b"] +[42.252725264,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# by default, length is 128 characters but you can change it\r\n\u001b[?2004l\r"] +[42.252948563000004,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[42.759563218000004,"o","p"] +[42.809042122,"o","a"] +[42.848246964,"o","s"] +[42.936120602,"o","u"] +[43.064070929,"o","m"] +[43.126054,"o","a"] +[43.196697841,"o","n"] +[43.302439438,"o"," "] +[43.376980337,"o","g"] +[43.413555212999995,"o","e"] +[43.468375406,"o","n"] +[43.52017385,"o","e"] +[43.570395353,"o","r"] +[43.641676779,"o","a"] +[43.733059731000004,"o","t"] +[43.774839724,"o","e"] +[43.95795298200001,"o"," "] +[44.201764909000005,"o","-"] +[44.270351168000005,"o","-"] +[44.44539046500001,"o","l"] +[44.50823499700001,"o","e"] +[44.59814833000001,"o","n"] +[44.687256171000016,"o","g"] +[44.726272334000015,"o","t"] +[44.78760339500001,"o","h"] +[45.05863414300001,"o"," "] +[45.30004632700002,"o","2"] +[45.35233522100002,"o","0"] +[45.431365180000014,"o","0"] +[46.48988610500001,"o","\r\n\u001b[?2004l\r"] +[46.49496659000001,"o","0tpf=8#ZCwx8\u003eBJ\"*L:{:-[m)%D3{)9u9{?$IO7Pp?2`xi(j$^v?Ve;x9EMz!uGr|2L`DW))(q\u003eBB\u003c*P+!SBOQ{+1yKkB_]]UAJ4Dq=wjPbUE|UEk+D!\u003eHMj9zL0.G3qR1Z`thxqQDpUO$rD2-.Q6ur^TQEVNUD{#7f\\k6g?Mz}h\u0026umuK7\u003e\u003c/\u0026n3pk}`vKD\\!1]5l9d.\r\n"] +[46.49565379700002,"o","\u001b]0;@demo:~\u0007"] +[46.49581708800002,"o","\u001b[?2004h[norbjd@demo ~]$ "] +[47.810115766000024,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[49.07728970000002,"o","\u001b[0m# you can add new entries without interactive prompt\u001b"] +[49.53391134400003,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# you can add new entries without interactive prompt\r\n\u001b[?2004l\r"] +[49.53407405800003,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[50.47415303900002,"o","p"] +[50.52694565400003,"o","a"] +[50.56779840800003,"o","s"] +[50.653594846000026,"o","u"] +[50.77972300700002,"o","m"] +[50.82936520200003,"o","a"] +[50.901663879000026,"o","n"] +[51.01398962300002,"o"," "] +[51.09069946500002,"o","a"] +[51.14333917500002,"o","d"] +[51.223227710000025,"o","d"] +[51.27792038600002,"o"," "] +[51.462416136000016,"o","-"] +[51.51622369400002,"o","-"] +[51.57904572300002,"o","d"] +[51.60531249400002,"o","e"] +[51.71230848900002,"o","s"] +[51.80701265300002,"o","c"] +[51.841331459000024,"o","r"] +[51.88250387100002,"o","i"] +[51.99701067500002,"o","p"] +[52.06181527600002,"o","t"] +[52.112657054000024,"o","i"] +[52.19322587200003,"o","o"] +[52.300811561000025,"o","n"] +[52.973696844000024,"o"," "] +[53.03701740100002,"o","\""] +[53.171590534000025,"o","M"] +[53.32035036600003,"o","y"] +[53.443245596000025,"o"," "] +[53.50095598200003,"o","t"] +[53.63009146700003,"o","w"] +[53.69697096000003,"o","i"] +[53.76484404700003,"o","t"] +[53.83958978100002,"o","t"] +[53.886752030000025,"o","e"] +[53.94988737000003,"o","r"] +[54.02044235300003,"o"," "] +[54.059678930000025,"o","a"] +[54.137173492000024,"o","c"] +[54.21432762400003,"o","c"] +[54.26874045500003,"o","o"] +[54.366416746000034,"o","u"] +[54.46467407600003,"o","n"] +[54.48932293900003,"o","t"] +[54.59894601500003,"o","\""] +[54.76684436000003,"o"," "] +[54.95171169800003,"o","-"] +[55.00988714600003,"o","-"] +[55.16849232900003,"o","t"] +[55.22169634500003,"o","a"] +[55.49340853000003,"o","g"] +[55.57353709700003,"o","s"] +[55.84841470400002,"o"," "] +[56.00532062200002,"o","t"] +[56.13888573600002,"o","w"] +[56.21306024000002,"o","i"] +[56.27644657500002,"o","t"] +[56.346940973000024,"o","t"] +[56.395324154000015,"o","e"] +[56.453610386000015,"o","r"] +[56.52982400800001,"o",","] +[56.67267247500002,"o","p"] +[56.71382815400002,"o","e"] +[56.75614475700002,"o","r"] +[56.82781969700002,"o","s"] +[56.880953319000014,"o","o"] +[57.07613395500001,"o"," "] +[57.271011666000014,"o","-"] +[57.33435011000002,"o","-"] +[57.409188164000014,"o","s"] +[57.491245065000015,"o","i"] +[57.56189409900002,"o","t"] +[57.599531232000025,"o","e"] +[57.70616198600002,"o"," "] +[57.828319810000025,"o","h"] +[57.88975069200002,"o","t"] +[57.97809400800002,"o","t"] +[58.063142077000016,"o","p"] +[58.154586768000016,"o","s"] +[58.41975981200001,"o",":"] +[58.53961252100001,"o","/"] +[58.60944452300001,"o","/"] +[58.822049982,"o","t"] +[58.954833261,"o","w"] +[59.030824738,"o","i"] +[59.088804006,"o","t"] +[59.156659783,"o","t"] +[59.222412972,"o","e"] +[59.2787074,"o","r"] +[59.445720627,"o","."] +[59.584201203999996,"o","c"] +[59.650850473999995,"o","o"] +[59.769262413999996,"o","m"] +[60.011046763,"o"," "] +[60.175295006000006,"o","-"] +[60.230462553,"o","-"] +[60.390134032000006,"o","i"] +[60.63995426,"o","d"] +[60.701437072000004,"o"," "] +[60.817611983,"o","d"] +[60.84860323,"o","e"] +[61.5558956,"o","m"] +[61.657838229,"o","o"] +[61.787257021,"o"," "] +[61.943387451,"o","-"] +[61.991405391,"o","-"] +[62.431898731,"o","p"] +[62.491237411,"o","a"] +[62.520574976000006,"o","s"] +[62.613148279,"o","s"] +[62.737000752,"o","w"] +[62.806223442000004,"o","o"] +[62.899016581999994,"o","r"] +[62.972864504,"o","d"] +[63.609285695,"o"," "] +[63.789666858,"o","$"] +[63.905313813999996,"o","("] +[64.03621895799999,"o","p"] +[64.077808541,"o","a"] +[64.121879224,"o","s"] +[64.188037467,"o","u"] +[64.30615225199999,"o","m"] +[64.38575344099999,"o","a"] +[64.450755953,"o","n"] +[64.538504382,"o"," "] +[64.620127956,"o","g"] +[64.66588117100001,"o","e"] +[64.711880765,"o","n"] +[64.753881172,"o","e"] +[64.810265035,"o","r"] +[64.874171824,"o","a"] +[64.942536515,"o","t"] +[64.974570978,"o","e"] +[65.16177112700001,"o",")"] +[65.344371432,"o"," "] +[65.393172938,"o","t"] +[65.50118849200001,"o","w"] +[65.557601782,"o","i"] +[65.627228372,"o","t"] +[65.713371587,"o","t"] +[65.745051772,"o","e"] +[65.80073458,"o","r"] +[66.21927884,"o","\r\n\u001b[?2004l\r"] +[66.2282108,"o","Enter current master password: "] +[67.556620013,"o","βœ”\r\n"] +[68.46416715699999,"o","New entry: twitter\r\n"] +[68.47405520599999,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[69.48960001699999,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[71.03956107399999,"o","\u001b[0m# you can use the list command to list all entries\u001b"] +[71.25693255299998,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# you can use the list command to list all entries\r\n\u001b[?2004l\r"] +[71.25714777399999,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[71.82772518699998,"o","p"] +[71.87741065699998,"o","a"] +[71.91990324499999,"o","s"] +[71.98537081899998,"o","u"] +[72.11024342799998,"o","m"] +[72.17113077599998,"o","a"] +[72.24362447899999,"o","n"] +[72.33421463299999,"o"," "] +[72.47806707999999,"o","l"] +[72.60147930199999,"o","i"] +[72.66310636399999,"o","s"] +[72.72114102399999,"o","t"] +[73.40713343899998,"o","\r\n\u001b[?2004l\r"] +[73.41148632699998,"o","Unique ID\tDescription\t\tTags\t\tSite\t\t\t\r\n---------\t-----------\t\t"] +[73.41169000799997,"o","----\t\t----\t\t\t\r\nmail\t\tMy personal mail\tmail,perso\thttps://gmail.com\t\r\ntwitter\t\tMy twitter account\ttwitter,perso\thttps://twitter.com\t\r\n"] +[73.41230278199997,"o","\u001b]0;@demo:~\u0007"] +[73.41238702399997,"o","\u001b[?2004h[norbjd@demo ~]$ "] +[75.52859164699997,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[77.07725733899997,"o","\u001b[0m# you can search for particular entries\u001b"] +[77.22683997299997,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# you can search for particular entries\r\n\u001b[?2004l\r"] +[77.22689305699997,"o","\u001b]0;@demo:~\u0007\u001b[?2004h"] +[77.22706500299998,"o","[norbjd@demo ~]$ "] +[77.52299156999997,"o","p"] +[77.57049251799998,"o","a"] +[77.60743609699999,"o","s"] +[77.68603042099998,"o","u"] +[77.80627029699997,"o","m"] +[77.87478096899997,"o","a"] +[77.94750777999997,"o","n"] +[78.05142144099996,"o"," "] +[78.08772339599996,"o","s"] +[78.19802156199997,"o","e"] +[78.26344816899997,"o","a"] +[78.30596902999997,"o","r"] +[78.41254208399998,"o","c"] +[78.45909790599997,"o","h"] +[78.56471429399997,"o"," "] +[78.76359349999998,"o","g"] +[78.83609181899998,"o","m"] +[78.88248550399999,"o","a"] +[78.97064360299998,"o","i"] +[79.08515156099999,"o","l"] +[79.419427856,"o","\r\n\u001b[?2004l\r"] +[79.42064532599998,"o","Unique ID\tDescription\tTags\t\tSite\t\t\t\r\n---------\t-----------\t----"] +[79.42071687399998,"o","\t\t----\t\t\t\r\nmail\t\tMy personal mailmail,perso\thttps://gmail.com\t\r\n"] +[79.42087670299999,"o","\u001b]0;@demo:~\u0007"] +[79.420915939,"o","\u001b[?2004h[norbjd@demo ~]$ "] +[80.983132476,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[82.533611102,"o","\u001b[0m# you can use --all to display all info\u001b"] +[82.69465757199998,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# you can use --all to display all info\r\n\u001b[?2004l\r"] +[82.694886064,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[84.42476149099998,"o","\u001b[0m# your master password will be asked to display any sensitive data\u001b"] +[85.012345402,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# your master password will be asked to display any sensitive data\r\n\u001b[?2004l\r"] +[85.01247753499999,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[86.39075108799999,"o","\u001b[0m# be careful nobody is behind you!\u001b"] +[86.935912805,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# be careful nobody is behind you!\r\n\u001b[?2004l\r"] +[86.935934154,"o","\u001b]0;@demo:~\u0007"] +[86.93601493399999,"o","\u001b[?2004h[norbjd@demo ~]$ "] +[87.691811655,"o","p"] +[87.741370762,"o","a"] +[87.782990926,"o","s"] +[87.85840251799999,"o","u"] +[87.976020506,"o","m"] +[88.045196459,"o","a"] +[88.10861452900001,"o","n"] +[88.20328349600001,"o"," "] +[88.26692140300001,"o","g"] +[88.29068110900002,"o","e"] +[88.36699743700001,"o","t"] +[88.40366347900002,"o"," "] +[88.52452074500002,"o","m"] +[88.58525576500001,"o","a"] +[88.64843149500001,"o","i"] +[88.75019698500002,"o","l"] +[88.91173275100002,"o"," "] +[89.12114970800002,"o","-"] +[89.18765703600002,"o","-"] +[89.30615956500002,"o","a"] +[89.39670298200001,"o","l"] +[89.48613452000002,"o","l"] +[89.75205708700003,"o","\r\n\u001b[?2004l\r"] +[89.75325147700002,"o","Enter current master password: "] +[90.99240571400003,"o","βœ”\r\n"] +[91.90072719200002,"o","Unique ID\tDescription\tTags\t\tSite\t\t\tID\t\tPassword\t\r\n---------\t-----------\t----\t\t----\t\t\t--\t\t--------\t\r\nmail\t\tMy personal mailmail,perso\thttps://gmail.com\tdemo@gmail.com\tmysuperpassword\t\r\n"] +[91.91025000000002,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[93.28249044100004,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[94.94396482600003,"o","\u001b[0m# you can also use --id or --password to display only the id or the password\u001b"] +[95.12929953400003,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# you can also use --id or --password to display only the id or the password\r\n\u001b[?2004l\r"] +[95.12951706300002,"o","\u001b]0;@demo:~\u0007"] +[95.12964861900004,"o","\u001b[?2004h[norbjd@demo ~]$ "] +[97.07262457100002,"o","\u001b[0m# --password can for example be used in interactive scripts\u001b"] +[97.42915979500003,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# --password can for example be used in interactive scripts\r\n\u001b[?2004l\r"] +[97.42934155000003,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[98.13313787700004,"o","G"] +[98.20357688300003,"o","M"] +[98.26960645500003,"o","A"] +[98.34192994300003,"o","I"] +[98.43795716800003,"o","L"] +[98.60354809000003,"o","_"] +[98.75278221600003,"o","P"] +[98.80458296800003,"o","A"] +[98.86393318800003,"o","S"] +[98.96020967700005,"o","S"] +[99.09468818200004,"o","W"] +[99.29701323200004,"o","O"] +[99.36831254500004,"o","R"] +[99.47486482700003,"o","D"] +[99.98563561300004,"o","="] +[100.11871777800005,"o","$"] +[100.23006089100004,"o","("] +[100.36618673100004,"o","p"] +[100.42433794500003,"o","a"] +[100.47234434500004,"o","s"] +[100.52093005300004,"o","u"] +[100.65159689600004,"o","m"] +[100.73555732000004,"o","a"] +[100.80470846200004,"o","n"] +[100.90353240600004,"o"," "] +[100.95235699200002,"o","g"] +[100.98926191200003,"o","e"] +[101.06978687800003,"o","t"] +[101.10400644000005,"o"," "] +[101.23261974000003,"o","m"] +[101.31394615900004,"o","a"] +[101.36970455400004,"o","i"] +[101.47315776900004,"o","l"] +[101.59623475400005,"o"," "] +[101.78433617700004,"o","-"] +[101.85337768900004,"o","-"] +[102.10829744300004,"o","p"] +[102.17838678700005,"o","a"] +[102.23257978100004,"o","s"] +[102.43339886100004,"o","s"] +[102.71426745900004,"o","w"] +[102.79390326800005,"o","o"] +[102.87242892900004,"o","r"] +[102.97795299700003,"o","d"] +[103.05475054300003,"o",")"] +[103.38042780700003,"o","\r\n\u001b[?2004l\r"] +[103.38509977600003,"o","Enter current master password: "] +[104.68237580000003,"o","βœ”\r\n"] +[105.60091017800003,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[105.85759555200003,"o","e"] +[105.94544264800004,"o","c"] +[106.01064826400003,"o","h"] +[106.12929965900003,"o","o"] +[106.24996824600004,"o"," "] +[106.61036821600003,"o","$"] +[106.76675656500004,"o","G"] +[106.93013469200004,"o","M"] +[107.02393354300004,"o","A"] +[107.07616704500003,"o","I"] +[107.17872879500004,"o","L"] +[107.36336187100004,"o","_"] +[107.71634739100004,"o","P"] +[107.77957562800003,"o","A"] +[107.82736046000004,"o","S"] +[107.93769497700005,"o","S"] +[108.10948691800004,"o","W"] +[108.17315540600005,"o","O"] +[108.26674769100005,"o","R"] +[108.37712836800006,"o","D"] +[108.93189944300005,"o","\r\n\u001b[?2004l\r"] +[108.93201599700005,"o","mysuperpassword\r\n\u001b]0;@demo:~\u0007"] +[108.93205177100006,"o","\u001b[?2004h[norbjd@demo ~]$ "] +[110.30969697100005,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[112.29028813000005,"o","\u001b[0m# you can of course update an entry, interactively or not\u001b"] +[112.56904260000006,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# you can of course update an entry, interactively or not\r\n\u001b[?2004l\r"] +[112.56911934100005,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[114.47077574600006,"o","\u001b[0m# for example to rotate a password\u001b"] +[114.84959471000006,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# for example to rotate a password\r\n\u001b[?2004l\r"] +[114.84965471800005,"o","\u001b]0;@demo:~\u0007\u001b[?2004h"] +[114.84978742800006,"o","[norbjd@demo ~]$ "] +[115.37007929100005,"o","p"] +[115.42830263000006,"o","a"] +[115.47179689000006,"o","s"] +[115.54747274800008,"o","u"] +[115.66684770100007,"o","m"] +[115.72743616300006,"o","a"] +[115.79638824000006,"o","n"] +[115.90553990700006,"o"," "] +[116.04039346200005,"o","u"] +[116.15474389900005,"o","p"] +[116.18400183800004,"o","d"] +[116.21565432700004,"o","a"] +[116.31809202200004,"o","t"] +[116.36169129600006,"o","e"] +[116.40688712900005,"o"," "] +[116.52383118100005,"o","m"] +[116.58160965300004,"o","a"] +[116.65066039700005,"o","i"] +[116.75439749600005,"o","l"] +[116.91745077200004,"o"," "] +[117.08635078900004,"o","-"] +[117.14372709200005,"o","-"] +[117.32598927500004,"o","p"] +[117.37480059000005,"o","a"] +[117.41706180100005,"o","s"] +[117.51993340000006,"o","s"] +[117.64907292400005,"o","w"] +[117.71969887800006,"o","o"] +[117.79558995700005,"o","r"] +[117.87425236700005,"o","d"] +[118.00226097100004,"o"," "] +[118.21384110100004,"o","$"] +[118.34617374300004,"o","("] +[118.45232789500004,"o","p"] +[118.51344777400004,"o","a"] +[118.56005552600006,"o","s"] +[118.60401542500004,"o","u"] +[118.71944254400005,"o","m"] +[118.78326797600005,"o","a"] +[118.87417663400005,"o","n"] +[118.96751273100006,"o"," "] +[119.02092042800005,"o","g"] +[119.06906992400006,"o","e"] +[119.14300151100007,"o","n"] +[119.18686505100007,"o","e"] +[119.27708707900007,"o","r"] +[119.35113406600007,"o","a"] +[119.43366410700007,"o","t"] +[119.48342631800007,"o","e"] +[119.60421088200006,"o",")"] +[120.07711662000007,"o","\r\n\u001b[?2004l\r"] +[120.08243309800007,"o","Enter current master password: "] +[121.26036128800007,"o","βœ”\r\n"] +[121.71642843500007,"o","Updated: mail\r\n"] +[121.72640072600007,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[122.34213877500007,"o","p"] +[122.38823876700006,"o","a"] +[122.44207459800008,"o","s"] +[122.76808075200007,"o","u"] +[122.92675171300007,"o","m"] +[122.97923217100008,"o","a"] +[123.05177491900007,"o","n"] +[123.14694299100007,"o"," "] +[123.18253235100008,"o","g"] +[123.22135521900007,"o","e"] +[123.28444555300007,"o","t"] +[123.32365097200007,"o"," "] +[123.44188750400006,"o","m"] +[123.49112406800006,"o","a"] +[123.55718537500006,"o","i"] +[123.65272792000007,"o","l"] +[123.75897093000006,"o"," "] +[123.94020028300005,"o","-"] +[123.99850959500006,"o","-"] +[124.59885270700006,"o","p"] +[124.64762074700006,"o","a"] +[124.67171742100007,"o","s"] +[124.75500910200006,"o","s"] +[125.07997377100006,"o","w"] +[125.13915550700007,"o","o"] +[125.21592593400007,"o","r"] +[125.30383188400006,"o","d"] +[125.44424598800008,"o","\r\n"] +[125.44445659300007,"o","\u001b[?2004l\r"] +[125.44770568000007,"o","Enter current master password: "] +[126.69823027900009,"o","βœ”\r\n"] +[127.6026431590001,"o","iR8z%Ho3|m:n{=X31rWd+Mh*1dc}tsowCzIxGJ:!M.IAyjPgeYf+0MSofB_J8u!h?8Hq:tF,yHByswLVI~b/x2JqQFay!JJh%so8)),,n22\u003eRNO2D$3IjL}8(LX1#+Dx\r\n"] +[127.61370309500008,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[129.27580583300008,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[130.9031287420001,"o","\u001b[0m# finally you can remove entries\u001b"] +[131.07853385700008,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# finally you can remove entries\r\n\u001b[?2004l\r"] +[131.0787219590001,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[131.41354525000008,"o","p"] +[131.46249100600008,"o","a"] +[131.51433618700008,"o","s"] +[131.58693836600008,"o","u"] +[131.7217446070001,"o","m"] +[131.79371093200007,"o","a"] +[131.83984900600007,"o","n"] +[131.93839599000006,"o"," "] +[131.98975533700005,"o","r"] +[132.02904499800005,"o","e"] +[132.07574391600005,"o","m"] +[132.17605421200005,"o","o"] +[132.21047630100006,"o","v"] +[132.25351184600007,"o","e"] +[132.31955411200005,"o"," "] +[132.40248574500006,"o","t"] +[132.54868023500006,"o","w"] +[132.62386391200008,"o","i"] +[132.69785435300008,"o","t"] +[132.77781847500006,"o","t"] +[132.83142696800007,"o","e"] +[132.90381565700005,"o","r"] +[133.36165938700006,"o","\r\n\u001b[?2004l\r"] +[133.36369215800005,"o","Enter current master password: "] +[134.63814379300004,"o","βœ”\r\n"] +[134.63833131800004,"o","Removed entry: twitter\r\n"] +[134.64550169700001,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[135.26005574200002,"o","p"] +[135.31632709000002,"o","a"] +[135.35989405200002,"o","s"] +[135.43622134300003,"o","u"] +[135.583579065,"o","m"] +[135.637851108,"o","a"] +[135.697644464,"o","n"] +[135.787558374,"o"," "] +[135.90567258200002,"o","l"] +[136.02289070900002,"o","i"] +[136.06474225000002,"o","s"] +[136.11368050500002,"o","t"] +[136.37674519100003,"o","\r\n\u001b[?2004l\r"] +[136.37919917200003,"o","Unique ID\tDescription\tTags\t\tSite\t\t\t\r\n---------\t-----------\t----\t\t----\t\t\t\r\nmail\t\tMy personal mailmail,perso\thttps://gmail.com\t\r\n"] +[136.37957422200003,"o","\u001b]0;@demo:~\u0007\u001b[?2004h"] +[136.37958345400003,"o","[norbjd@demo ~]$ "] +[137.672989539,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[139.49688423100002,"o","\u001b[0m# if you want to separate entries (use different master passwords for example)\u001b"] +[139.65757873100003,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# if you want to separate entries (use different master passwords for example)\r\n\u001b[?2004l\r"] +[139.65776856400004,"o","\u001b]0;@demo:~\u0007\u001b[?2004h"] +[139.65788511800002,"o","[norbjd@demo ~]$ "] +[141.402092648,"o","\u001b[0m# you can use profiles\u001b"] +[141.757864501,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# you can use profiles\r\n\u001b[?2004l\r"] +[141.758092416,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[142.50153614400003,"o","p"] +[142.54009070700002,"o","a"] +[142.64040273400002,"o","s"] +[142.68423453900002,"o","u"] +[142.814743861,"o","m"] +[142.885119974,"o","a"] +[142.935102022,"o","n"] +[143.038135758,"o"," "] +[143.238002788,"o","-"] +[143.297567652,"o","-"] +[143.48734699099998,"o","p"] +[143.54618252699998,"o","r"] +[143.592537553,"o","o"] +[143.65271923,"o","f"] +[143.702479133,"o","i"] +[143.80737254,"o","l"] +[143.856733159,"o","e"] +[143.94093188399998,"o"," "] +[144.056829258,"o","w"] +[144.151920012,"o","o"] +[144.211701251,"o","r"] +[144.273731636,"o","k"] +[144.39438695200002,"o"," "] +[144.51300603500002,"o","m"] +[144.53932415900002,"o","a"] +[144.64067420900003,"o","s"] +[144.72693340100003,"o","t"] +[144.76456995700002,"o","e"] +[144.82434600300002,"o","r"] +[144.895268535,"o","-"] +[145.07705584700003,"o","p"] +[145.119731921,"o","a"] +[145.165423397,"o","s"] +[145.247830537,"o","s"] +[145.36526677,"o","w"] +[145.44651933300003,"o","o"] +[145.517442442,"o","r"] +[145.603620277,"o","d"] +[145.728799273,"o","\r\n\u001b[?2004l\r"] +[145.73098898799998,"o","Enter new master password: "] +[146.806794525,"o","βœ”\r\nEnter new master password again: "] +[147.55885459399997,"o","βœ”\r\n"] +[148.03313820799997,"o","Master password has been set!\r\n"] +[148.03931903199998,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[149.385192304,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[151.50553517099996,"o","\u001b[0m# you can add, update, get, list, remove entries by specifying a flag --profile\u001b"] +[152.09480738399998,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# you can add, update, get, list, remove entries by specifying a flag --profile\r\n\u001b[?2004l\r"] +[152.09486796899998,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[152.90175400099997,"o","p"] +[152.97465160399994,"o","a"] +[153.04866743299996,"o","s"] +[153.13715442199995,"o","u"] +[153.28784143399997,"o","m"] +[153.36521309499997,"o","a"] +[153.44544552199997,"o","n"] +[153.86011061099998,"o"," "] +[154.10176975099998,"o","-"] +[154.17307598799997,"o","-"] +[154.41435026899998,"o","p"] +[154.47756061899995,"o","r"] +[154.53737763199996,"o","o"] +[154.60372859299997,"o","f"] +[154.66999704299997,"o","i"] +[154.79886826199996,"o","l"] +[154.89647127399994,"o","e"] +[155.70271279699995,"o"," "] +[155.78982017899995,"o","w"] +[156.26356718299996,"o","o"] +[156.32151990899996,"o","r"] +[156.37079628599994,"o","k"] +[156.52112324999996,"o"," "] +[156.63637784599996,"o","a"] +[156.74365426299997,"o","d"] +[156.83952165899996,"o","d"] +[157.91838702699997,"o","\r\n\u001b[?2004l\r"] +[157.91929118599995,"o","Enter current master password: "] +[159.27984218399996,"o","βœ”\r\nEnter unique id (leave empty to generate a random one): "] +[159.98150649599995,"o","m"] +[160.04534981499998,"o","a"] +[160.17107926899996,"o","i"] +[160.30396871599999,"o","l"] +[161.07626763699997,"o","\r\n"] +[161.07643727499996,"o","Enter description: "] +[161.412060288,"o","M"] +[161.643063969,"o","y"] +[161.779530239,"o"," "] +[161.863244861,"o","w"] +[161.933911205,"o","o"] +[161.99010869699998,"o","r"] +[162.028845015,"o","k"] +[162.123132008,"o"," "] +[162.25497073800003,"o","m"] +[162.32877653900002,"o","a"] +[162.40128986000002,"o","i"] +[162.515859557,"o","l"] +[163.148727005,"o","\r\nEnter tags (comma-separated): "] +[163.93446137100003,"o","m"] +[163.993461929,"o","a"] +[164.090386389,"o","i"] +[164.210226404,"o","l"] +[164.51757930200003,"o","\r\nEnter site: "] +[164.808968918,"o","h"] +[164.88889149599999,"o","t"] +[164.967221554,"o","t"] +[165.036436743,"o","p"] +[165.126879762,"o","s"] +[165.244858952,"o",":"] +[165.368219244,"o","/"] +[165.44998879899998,"o","/"] +[165.591323795,"o","g"] +[165.62775672899997,"o","m"] +[165.714813335,"o","a"] +[165.781642052,"o","i"] +[165.88742692399998,"o","l"] +[166.042301802,"o","."] +[166.136105846,"o","c"] +[166.206629671,"o","o"] +[166.34281147999997,"o","m"] +[166.518176205,"o","\r\nEnter id: "] +[166.79099623,"o","d"] +[166.82336592999997,"o","e"] +[166.85804420699998,"o","m"] +[166.96229137399996,"o","o"] +[167.10722454199998,"o","w"] +[167.16541499199997,"o","o"] +[167.226686622,"o","r"] +[167.28365267799998,"o","k"] +[167.48984882099995,"o","@"] +[167.62806974799994,"o","g"] +[167.69644770999997,"o","m"] +[167.77212933799998,"o","a"] +[167.83251008,"o","i"] +[167.936906113,"o","l"] +[168.13670909599998,"o","."] +[168.26748037599998,"o","c"] +[168.31085865899996,"o","o"] +[168.42287109199995,"o","m"] +[168.79358551399997,"o","\r\nEnter password: "] +[170.69867276099998,"o","βœ”\r\n"] +[171.602249568,"o","New entry: mail\r\n"] +[171.61384726799997,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[173.35061438299996,"o","p"] +[173.389837689,"o","a"] +[173.43419167899998,"o","s"] +[173.51186857299996,"o","u"] +[173.637128349,"o","m"] +[173.68596332099997,"o","a"] +[173.76547334399996,"o","n"] +[173.86047004699998,"o"," "] +[174.03923964899997,"o","-"] +[174.097175065,"o","-"] +[174.27851231699998,"o","p"] +[174.33097315699996,"o","r"] +[174.38232384899996,"o","o"] +[174.43638470999994,"o","f"] +[174.48942889699993,"o","i"] +[174.59152904699994,"o","l"] +[174.65572375899993,"o","e"] +[174.70926243499994,"o"," "] +[174.80020340499993,"o","w"] +[174.85368438099994,"o","o"] +[174.91441824699993,"o","r"] +[174.94860145799993,"o","k"] +[175.17719789499995,"o"," "] +[175.30405480699994,"o","l"] +[175.39635691999993,"o","i"] +[175.44287639099994,"o","s"] +[175.49693263599997,"o","t"] +[175.66079371199996,"o","\r\n\u001b[?2004l\r"] +[175.66503466199998,"o","Unique ID\tDescription\tTags\tSite\t\t\t\r\n---------\t-----------\t----\t----\t\t\t\r\nmail\t\tMy work mail\tmail\thttps://gmail.com\t\r\n"] +[175.66582284399996,"o","\u001b]0;@demo:~\u0007"] +[175.66586554199998,"o","\u001b[?2004h[norbjd@demo ~]$ "] +[177.316516561,"o","\u001b[H\u001b[2J[norbjd@demo ~]$ "] +[177.97995750799998,"o","\u001b[0m# more info can be found running --help\u001b"] +[178.23368056399994,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# more info can be found running --help\r\n\u001b[?2004l\r"] +[178.72808532199994,"o","\u001b]0;@demo:~\u0007\u001b[?2004h[norbjd@demo ~]$ "] +[179.03194159999998,"o","\u001b[0m# thanks for watching!\u001b"] +[179.99250061899997,"o","\r\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C\u001b[C# thanks for watching!\r\n\u001b[?2004l\r"] +[179.992549087,"o","\u001b]0;@demo:~\u0007"] diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..f323690 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..7241adc --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,194 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "errors" + "strings" + + "github.com/norbjd/pasuman/pkg/add" + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/get" + "github.com/norbjd/pasuman/pkg/masterpassword" + "github.com/norbjd/pasuman/pkg/util" + "github.com/spf13/cobra" +) + +var addCmd *cobra.Command + +var ( + addCmdDescription string + addCmdTags []string + addCmdSite string + addCmdID string + addCmdPassword string +) + +var ( + errEmptyPasswordNotAllowed = errors.New("empty password not allowed") + errUniqueIDAlreadyExists = errors.New("unique id already exists") +) + +func addCmdInit() { + addCmd = &cobra.Command{ + Use: "add [unique id]", + Short: "Add an entry", + Args: cobra.MaximumNArgs(1), + RunE: addCmdRunE, + } + + addCmd.Flags().StringVar(&addCmdDescription, "description", "", "Description") + addCmd.Flags().StringSliceVar(&addCmdTags, "tags", nil, "Tags") + addCmd.Flags().StringVar(&addCmdSite, "site", "", "Site") + addCmd.Flags().StringVar(&addCmdID, "id", "", "ID") + addCmd.Flags().StringVar(&addCmdPassword, "password", "", "Password") +} + +// nolint: funlen, gocognit +func addCmdRunE(cmd *cobra.Command, args []string) error { + masterPasswordSet, err := masterpassword.IsSet() + if err != nil { + return err + } + + if !masterPasswordSet { + return errNoMasterPasswordSet + } + + var uniqueID string + + if len(args) == 1 { + uniqueID = strings.TrimSpace(args[0]) + + if _, err := get.NotSensitive(uniqueID); !errors.Is(err, get.ErrNotFound) { + return errUniqueIDAlreadyExists + } + } + + cmdPrintf(addCmd, "Enter current master password: ") + + masterPassword, err := util.ReadPassword() + if err != nil { + return err + } + + correct, err := masterpassword.IsCorrect(masterPassword) + if err != nil { + return err + } + + if !correct { + cmdPrintln(addCmd, "✘") + + return util.ErrMasterPasswordIncorrect + } + + cmdPrintln(addCmd, "βœ”") + + interactive := addCmdDescription == "" && len(addCmdTags) == 0 && addCmdSite == "" && + addCmdID == "" && addCmdPassword == "" + + // nolint: nestif + if interactive { + if uniqueID == "" { + cmdPrintf(addCmd, "Enter unique id (leave empty to generate a random one): ") + + uniqueID, err = util.ReadLine() + if err != nil { + return err + } + + uniqueID = strings.TrimSpace(uniqueID) + + if _, err := get.NotSensitive(uniqueID); err == nil { + return errUniqueIDAlreadyExists + } + } + + cmdPrintf(addCmd, "Enter description: ") + + addCmdDescription, err = util.ReadLine() + if err != nil { + return err + } + + addCmdDescription = strings.TrimSpace(addCmdDescription) + + cmdPrintf(addCmd, "Enter tags (comma-separated): ") + + tagsCommaSeparated, err := util.ReadLine() + if err != nil { + return err + } + + tagsCommaSeparatedTrimmed := strings.TrimSpace(tagsCommaSeparated) + if tagsCommaSeparatedTrimmed != "" { + addCmdTags = strings.Split(tagsCommaSeparatedTrimmed, ",") + } + + cmdPrintf(addCmd, "Enter site: ") + + addCmdSite, err = util.ReadLine() + if err != nil { + return err + } + + addCmdSite = strings.TrimSpace(addCmdSite) + + cmdPrintf(addCmd, "Enter id: ") + + addCmdID, err = util.ReadLine() + if err != nil { + return err + } + + cmdPrintf(addCmd, "Enter password: ") + + addCmdPassword, err = util.ReadPassword() + if err != nil { + return err + } + + if addCmdPassword == "" { + cmdPrintln(addCmd, "✘") + + return errEmptyPasswordNotAllowed + } + + cmdPrintln(addCmd, "βœ”") + } + + uniqueID, err = add.Add( + masterPassword, + data.Entry{ + UniqueID: uniqueID, + Description: addCmdDescription, + Tags: addCmdTags, + Site: addCmdSite, + ID: addCmdID, + Password: addCmdPassword, + }, + ) + if err != nil { + return err + } + + cmdPrintf(addCmd, "New entry: %s\n", uniqueID) + + return nil +} diff --git a/cmd/add_test.go b/cmd/add_test.go new file mode 100644 index 0000000..f6a1fcc --- /dev/null +++ b/cmd/add_test.go @@ -0,0 +1,76 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/stretchr/testify/require" +) + +func TestAdd(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + tests := []struct { + args []string + output string + wantErr error + }{ + { + args: []string{ + "add", "id1", "--description=A desc", "--tags=tag1,tag2", + "--site=https://mysupersite.pasuman", "--id=myId", "--password=p4$$w0rd!", + }, + output: "" + + "Enter current master password: βœ”\n" + + "New entry: id1\n", + }, + { + args: []string{ + "add", "id1", "--description=A desc", "--tags=tag1,tag2", + "--site=https://mysupersite.pasuman", "--id=myId", "--password=p4$$w0rd!", + }, + wantErr: errUniqueIDAlreadyExists, + }, + { + args: []string{ + "add", "id2", "--description=Another desc", "--tags=tag1,tag3,tag4", + "--site=https://anothersite.pasuman", "--id=otherId", "--password=t0ps3cr3t!", + }, + output: "" + + "Enter current master password: βœ”\n" + + "New entry: id2\n", + }, + } + + for _, tt := range tests { + out, err := pasumantest.ExecuteCommand(RootCmd, tt.args...) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tt.output, out) + } + + pasumantest.Teardown(t, RootCmd) + } +} diff --git a/cmd/errors.go b/cmd/errors.go new file mode 100644 index 0000000..9988439 --- /dev/null +++ b/cmd/errors.go @@ -0,0 +1,24 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "errors" +) + +var errNoMasterPasswordSet = errors.New("no master password set: run `pasuman master-password`") diff --git a/cmd/generate.go b/cmd/generate.go new file mode 100644 index 0000000..ed1ef58 --- /dev/null +++ b/cmd/generate.go @@ -0,0 +1,47 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "github.com/norbjd/pasuman/pkg/generate" + "github.com/spf13/cobra" +) + +const passwordDefaultLength = 128 + +var generateCmdLength uint + +func generateCmdInit() { + generateCmd.Flags().UintVar(&generateCmdLength, "length", passwordDefaultLength, + "Length wanted for the generated password") +} + +var generateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate a random password", + RunE: func(cmd *cobra.Command, args []string) error { + generated, err := generate.Generate(generateCmdLength) + if err != nil { + return err + } + + cmdPrintln(cmd, generated) + + return nil + }, +} diff --git a/cmd/generate_test.go b/cmd/generate_test.go new file mode 100644 index 0000000..8327d6c --- /dev/null +++ b/cmd/generate_test.go @@ -0,0 +1,83 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "os" + "strings" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/stretchr/testify/require" +) + +func TestGenerate(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + tests := []struct { + args []string + passwordLength int + wantErrString string + }{ + { + args: []string{"generate"}, + passwordLength: passwordDefaultLength, + }, + { + args: []string{"generate", "--length=0"}, + passwordLength: 0, + }, + { + args: []string{"generate", "--length=1"}, + passwordLength: 1, + }, + { + args: []string{"generate", "--length=256"}, + passwordLength: 256, + }, + { + args: []string{"generate", "--length=-1"}, + wantErrString: `invalid argument "-1" for "--length" flag: ` + + `strconv.ParseUint: parsing "-1": invalid syntax`, + }, + } + + for _, tt := range tests { + out, err := pasumantest.ExecuteCommand(RootCmd, tt.args...) + if tt.wantErrString != "" { + require.EqualError(t, err, tt.wantErrString) + } else { + require.NoError(t, err) + + generatedPassword := strings.TrimSpace(out) + require.Equal(t, tt.passwordLength, len(generatedPassword)) + + for _, r := range generatedPassword { + if !strings.ContainsRune(constants.Alphabet, r) { + t.Fatalf("Password contains character '%s' but should not", string(r)) + + break + } + } + } + + pasumantest.Teardown(t, RootCmd) + } +} diff --git a/cmd/get.go b/cmd/get.go new file mode 100644 index 0000000..8275bbb --- /dev/null +++ b/cmd/get.go @@ -0,0 +1,225 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/norbjd/pasuman/pkg/get" + "github.com/norbjd/pasuman/pkg/masterpassword" + "github.com/norbjd/pasuman/pkg/util" + "github.com/spf13/cobra" +) + +var ( + getCmdNotSensitive bool + getCmdAll bool + getCmdID bool + getCmdPassword bool + getCmdCopyIDPasswordToClipboard bool + getCmdOutput = outputTable +) + +func getCmdInit() { + getCmd.Flags().BoolVar(&getCmdNotSensitive, "not-sensitive", true, + "Get only not sensitive data") + getCmd.Flags().BoolVar(&getCmdAll, "all", false, "Get all data") + getCmd.Flags().BoolVar(&getCmdID, "id", false, "Get id") + getCmd.Flags().BoolVar(&getCmdPassword, "password", false, "Get password") + getCmd.Flags().BoolVar(&getCmdCopyIDPasswordToClipboard, "copy-id-password-to-clipboard", false, + "Do not print id and password, just copy them to clipboard one after the other") + getCmd.MarkFlagsMutuallyExclusive("not-sensitive", "all", "id", "password", "copy-id-password-to-clipboard") + getCmd.Flags().Var(&getCmdOutput, "output", fmt.Sprintf("Output format: %s", outputMessageHelp)) + + if err := getCmd.RegisterFlagCompletionFunc("output", outputCompletion); err != nil { + log.Fatal(err) + } +} + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get an entry", + Args: cobra.ExactArgs(1), + ValidArgsFunction: autocomplete, + RunE: func(cmd *cobra.Command, args []string) error { + masterPasswordSet, err := masterpassword.IsSet() + if err != nil { + return err + } + + if !masterPasswordSet { + return errNoMasterPasswordSet + } + + uniqueID := args[0] + + if getCmdCopyIDPasswordToClipboard { + getCmdAll = true + } + + if getCmdNotSensitive && !getCmdAll && !getCmdID && !getCmdPassword { + entry, err := get.NotSensitive(uniqueID) + if err != nil { + return err + } + + switch getCmdOutput { + case outputTable: + headerColumns := []string{"Unique ID", "Description", "Tags", "Site"} + columns := []string{entry.UniqueID, entry.Description, strings.Join(entry.Tags, ","), entry.Site} + + util.RenderTable(cmd.OutOrStdout(), headerColumns, [][]string{columns}) + case outputJSON: + var jsonEntry struct { + UniqueID string `json:"unique_id"` + Description string `json:"description"` + Tags []string `json:"tags"` + Site string `json:"site"` + } + jsonEntry.UniqueID = entry.UniqueID + jsonEntry.Description = entry.Description + jsonEntry.Tags = entry.Tags + jsonEntry.Site = entry.Site + + result, err := json.MarshalIndent(jsonEntry, "", " ") + if err != nil { + return err + } + cmdPrintln(cmd, string(result)) + default: + return errInvalidOutput + } + + return nil + } + + cmdStderrPrintf(cmd, "Enter current master password: ") + masterPassword, err := util.ReadPassword() + if err != nil { + return err + } + + correct, err := masterpassword.IsCorrect(masterPassword) + if err != nil { + return err + } + + if !correct { + cmdStderrPrintln(cmd, "✘") + + return util.ErrMasterPasswordIncorrect + } + cmdStderrPrintln(cmd, "βœ”") + + entry, err := get.Sensitive(masterPassword, uniqueID) + if err != nil { + return err + } + + if getCmdID { + cmdPrintln(cmd, entry.ID) + + return nil + } + + if getCmdPassword { + cmdPrintln(cmd, entry.Password) + + return nil + } + + var entryID string + var entryPassword string + + if getCmdCopyIDPasswordToClipboard { + entryID = entry.ID + entry.ID = "XXX*" + + entryPassword = entry.Password + entry.Password = "XXX*" + } + + if getCmdAll { + switch getCmdOutput { + case outputTable: + headerColumns := []string{"Unique ID", "Description", "Tags", "Site", "ID", "Password"} + columns := []string{ + entry.UniqueID, entry.Description, strings.Join(entry.Tags, ","), entry.Site, + entry.ID, entry.Password, + } + + util.RenderTable(cmd.OutOrStdout(), headerColumns, [][]string{columns}) + case outputJSON: + var jsonEntry struct { + UniqueID string `json:"unique_id"` + Description string `json:"description"` + Tags []string `json:"tags"` + Site string `json:"site"` + ID string `json:"id"` + Password string `json:"password"` + } + jsonEntry.UniqueID = entry.UniqueID + jsonEntry.Description = entry.Description + jsonEntry.Tags = entry.Tags + jsonEntry.Site = entry.Site + jsonEntry.ID = entry.ID + jsonEntry.Password = entry.Password + + result, err := json.MarshalIndent(jsonEntry, "", " ") + if err != nil { + return err + } + cmdPrintln(cmd, string(result)) + default: + return errInvalidOutput + } + } + + if getCmdCopyIDPasswordToClipboard { + cmdPrintln(cmd, "\n*pasuman uses ANSI OSC 52 to copy to clipboard, it may not work on some terminals\n"+ + "(see https://github.com/ojroques/vim-oscyank/blob/main/README.md)\n") + } + + if getCmdCopyIDPasswordToClipboard { + cmdPrintf(cmd, "ID copied to clipboard, press enter to copy password") + + util.CopyToClipboard(entryID) + + _, err := util.ReadLine() + if err != nil { + return err + } + + cmdPrintf(cmd, "Password copied to clipboard, press enter to clear clipboard") + + util.CopyToClipboard(entryPassword) + + _, err = util.ReadLine() + if err != nil { + return err + } + + util.CopyToClipboard("") + } + + return nil + }, +} diff --git a/cmd/get_test.go b/cmd/get_test.go new file mode 100644 index 0000000..bc95f5c --- /dev/null +++ b/cmd/get_test.go @@ -0,0 +1,130 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/add" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/get" + "github.com/stretchr/testify/require" +) + +func TestGet(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + tests := []struct { + args []string + format cmdOutput + setup func() error + output string + wantErr error + }{ + { + args: []string{"get", "does-not-exist"}, + format: outputTable, + wantErr: get.ErrNotFound, + }, + { + args: []string{"get", "id1"}, + format: outputTable, + setup: func() error { + _, err := add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id1", + Description: "A desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + }) + + return err + }, + output: "" + + "Unique ID\tDescription\tTags\t\tSite\t\t\t\t\n" + + "---------\t-----------\t----\t\t----\t\t\t\t\n" + + "id1\t\tA desc\t\ttag1,tag2\thttps://mysupersite.pasuman\t\n", + }, + { + args: []string{"get", "id1", "--output=json"}, + format: outputJSON, + output: `{ + "unique_id": "id1", + "description": "A desc", + "tags": ["tag1", "tag2"], + "site": "https://mysupersite.pasuman" + }`, + }, + { + args: []string{"get", "id2"}, + format: outputTable, + setup: func() error { + _, err := add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id2", + Description: "Another desc", + Tags: []string{"tag1", "tag3", "tag4"}, + Site: "https://anothersite.pasuman", + ID: "otherId", + Password: "t0ps3cr3t!", + }) + + return err + }, + output: "" + + "Unique ID\tDescription\tTags\t\tSite\t\t\t\t\n" + + "---------\t-----------\t----\t\t----\t\t\t\t\n" + + "id2\t\tAnother desc\ttag1,tag3,tag4\thttps://anothersite.pasuman\t\n", + }, + { + args: []string{"get", "id2", "--output=json"}, + format: outputJSON, + output: `{ + "unique_id": "id2", + "description": "Another desc", + "tags": ["tag1", "tag3", "tag4"], + "site": "https://anothersite.pasuman" + }`, + }, + } + + for _, tt := range tests { + if tt.setup != nil { + require.NoError(t, tt.setup()) + } + + out, err := pasumantest.ExecuteCommand(RootCmd, tt.args...) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + + if tt.format == outputJSON { + require.JSONEq(t, tt.output, out) + } else { + require.Equal(t, tt.output, out) + } + } + + pasumantest.Teardown(t, RootCmd) + } +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..720a633 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,63 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "fmt" + "log" + + "github.com/norbjd/pasuman/pkg/list" + "github.com/norbjd/pasuman/pkg/masterpassword" + "github.com/spf13/cobra" +) + +var listCmd *cobra.Command + +var listCmdOutput = outputTable + +func listCmdInit() { + listCmd = &cobra.Command{ + Use: "list", + Short: "List entries", + RunE: listCmdRunE, + } + + listCmd.Flags().Var(&listCmdOutput, "output", fmt.Sprintf("Output format: %s", outputMessageHelp)) + + if err := listCmd.RegisterFlagCompletionFunc("output", outputCompletion); err != nil { + log.Fatal(err) + } +} + +func listCmdRunE(cmd *cobra.Command, args []string) error { + masterPasswordSet, err := masterpassword.IsSet() + if err != nil { + return err + } + + if !masterPasswordSet { + return errNoMasterPasswordSet + } + + entries, err := list.List(rootCmdProfile) + if err != nil { + return err + } + + return printEntries(listCmd.OutOrStdout(), entries, listCmdOutput) +} diff --git a/cmd/list_profiles.go b/cmd/list_profiles.go new file mode 100644 index 0000000..45aab2b --- /dev/null +++ b/cmd/list_profiles.go @@ -0,0 +1,40 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "github.com/norbjd/pasuman/pkg/listprofiles" + "github.com/spf13/cobra" +) + +var listProfilesCmd = &cobra.Command{ + Use: "list-profiles", + Short: "List different profiles", + RunE: func(cmd *cobra.Command, args []string) error { + profiles, err := listprofiles.ListProfiles() + if err != nil { + return err + } + + for _, profile := range profiles { + cmdPrintln(cmd, profile) + } + + return nil + }, +} diff --git a/cmd/list_profiles_test.go b/cmd/list_profiles_test.go new file mode 100644 index 0000000..8072f2a --- /dev/null +++ b/cmd/list_profiles_test.go @@ -0,0 +1,79 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/config" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/stretchr/testify/require" +) + +func TestListProfiles(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + tests := []struct { + args []string + setup func() error + output string + }{ + { + args: []string{"list-profiles"}, + output: "default\n", + }, + { + args: []string{"list-profiles"}, + setup: func() error { + profile := "otherprofile" + + config.Init(profile, true) + + return pasumantest.InitProfile(profile) + }, + output: "default\notherprofile\n", + }, + { + args: []string{"list-profiles"}, + setup: func() error { + profile := "anotherprofile" + + config.Init(profile, true) + + return pasumantest.InitProfile(profile) + }, + output: "anotherprofile\ndefault\notherprofile\n", + }, + } + + for _, tt := range tests { + if tt.setup != nil { + require.NoError(t, tt.setup()) + } + + out, err := pasumantest.ExecuteCommand(RootCmd, tt.args...) + require.NoError(t, err) + + require.Equal(t, tt.output, out) + + pasumantest.Teardown(t, RootCmd) + } +} diff --git a/cmd/list_test.go b/cmd/list_test.go new file mode 100644 index 0000000..aeaded2 --- /dev/null +++ b/cmd/list_test.go @@ -0,0 +1,140 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/add" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/data" + "github.com/stretchr/testify/require" +) + +func TestList(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + tests := []struct { + args []string + format cmdOutput + setup func() error + output string + }{ + { + args: []string{"list"}, + format: outputTable, + output: "Unique ID\tDescription\tTags\tSite\t\n---------\t-----------\t----\t----\t\n", + }, + { + args: []string{"list", "--output=json"}, + format: outputJSON, + output: "[]", + }, + { + args: []string{"list"}, + format: outputTable, + setup: func() error { + _, err := add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id1", + Description: "A desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + }) + + return err + }, + output: "" + + "Unique ID\tDescription\tTags\t\tSite\t\t\t\t\n" + + "---------\t-----------\t----\t\t----\t\t\t\t\n" + + "id1\t\tA desc\t\ttag1,tag2\thttps://mysupersite.pasuman\t\n", + }, + { + args: []string{"list", "--output=json"}, + format: outputJSON, + output: `[ + { + "unique_id": "id1", + "description": "A desc", + "tags": ["tag1", "tag2"], + "site": "https://mysupersite.pasuman" + } + ]`, + }, + { + args: []string{"list"}, + format: outputTable, + setup: func() error { + _, err := add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id2", + Description: "Another desc", + Tags: []string{"tag1", "tag3", "tag4"}, + Site: "https://anothersite.pasuman", + ID: "otherId", + Password: "t0ps3cr3t!", + }) + + return err + }, + output: "" + + "Unique ID\tDescription\tTags\t\tSite\t\t\t\t\n" + + "---------\t-----------\t----\t\t----\t\t\t\t\n" + + "id1\t\tA desc\t\ttag1,tag2\thttps://mysupersite.pasuman\t\n" + + "id2\t\tAnother desc\ttag1,tag3,tag4\thttps://anothersite.pasuman\t\n", + }, + { + args: []string{"list", "--output=json"}, + format: outputJSON, + output: `[ + { + "unique_id": "id1", + "description": "A desc", + "tags": ["tag1", "tag2"], + "site": "https://mysupersite.pasuman" + }, + { + "unique_id": "id2", + "description": "Another desc", + "tags": ["tag1", "tag3", "tag4"], + "site": "https://anothersite.pasuman" + } + ]`, + }, + } + + for _, tt := range tests { + if tt.setup != nil { + require.NoError(t, tt.setup()) + } + + out, err := pasumantest.ExecuteCommand(RootCmd, tt.args...) + require.NoError(t, err) + + if tt.format == outputJSON { + require.JSONEq(t, tt.output, out) + } else { + require.Equal(t, tt.output, out) + } + + pasumantest.Teardown(t, RootCmd) + } +} diff --git a/cmd/man.go b/cmd/man.go new file mode 100644 index 0000000..e762d7d --- /dev/null +++ b/cmd/man.go @@ -0,0 +1,53 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "log" + + "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" +) + +var manCmdPath string + +func manCmdInit() { + manCmd.Flags().StringVar(&manCmdPath, "path", "", "Output path") + + if err := manCmd.MarkFlagRequired("path"); err != nil { + log.Fatal(err) + } +} + +var manCmd = &cobra.Command{ + Use: "man", + Short: "Generate man pages", + RunE: func(cmd *cobra.Command, args []string) error { + header := &doc.GenManHeader{ + Title: "pasuman", + Section: "1", + } + + err := doc.GenManTree(RootCmd, header, manCmdPath) + if err != nil { + return err + } + + return nil + }, +} diff --git a/cmd/master_password.go b/cmd/master_password.go new file mode 100644 index 0000000..8876a3f --- /dev/null +++ b/cmd/master_password.go @@ -0,0 +1,112 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "errors" + + "github.com/norbjd/pasuman/pkg/masterpassword" + "github.com/norbjd/pasuman/pkg/util" + "github.com/spf13/cobra" +) + +var ( + errSamePasswordAsBefore = errors.New("same password as before") + errNewPasswordsMismatch = errors.New("new master passwords mismatch") +) + +var masterPasswordCmd = &cobra.Command{ + Use: "master-password", + Short: "Set or change master password", + RunE: func(cmd *cobra.Command, args []string) error { + masterPasswordSet, err := masterpassword.IsSet() + if err != nil { + return err + } + + var currentMasterPassword string + + if masterPasswordSet { + cmdStderrPrintf(cmd, "Enter current master password: ") + currentMasterPassword, err = util.ReadPassword() + if err != nil { + return err + } + + correct, err := masterpassword.IsCorrect(currentMasterPassword) + if err != nil { + return err + } + + if !correct { + cmdStderrPrintln(cmd, "✘") + + return util.ErrMasterPasswordIncorrect + } + + cmdStderrPrintln(cmd, "βœ”") + } + + cmdPrintf(cmd, "Enter new master password: ") + newMasterPassword, err := util.ReadPassword() + if err != nil { + return err + } + if newMasterPassword == "" { + cmdPrintln(cmd, "✘") + + return util.ErrMasterPasswordMustNotBeEmpty + } + + if masterPasswordSet { + sameAsCurrentPassword, err := masterpassword.IsCorrect(newMasterPassword) + if err != nil { + return err + } + + if sameAsCurrentPassword { + cmdPrintln(cmd, "✘") + + return errSamePasswordAsBefore + } + } + + cmdPrintln(cmd, "βœ”") + + cmdPrintf(cmd, "Enter new master password again: ") + newMasterPasswordAgain, err := util.ReadPassword() + if err != nil { + return err + } + + if newMasterPassword != newMasterPasswordAgain { + cmdPrintln(cmd, "✘") + + return errNewPasswordsMismatch + } + cmdPrintln(cmd, "βœ”") + + if err := masterpassword.SetMasterPassword(currentMasterPassword, newMasterPassword); err != nil { + return err + } + + cmdPrintln(cmd, "Master password has been set!") + + return nil + }, +} diff --git a/cmd/output.go b/cmd/output.go new file mode 100644 index 0000000..6cb42d6 --- /dev/null +++ b/cmd/output.go @@ -0,0 +1,62 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +type cmdOutput string + +const ( + outputTable cmdOutput = "table" + outputJSON cmdOutput = "json" +) + +var ( + outputMessageHelp = fmt.Sprintf(`must be "%s" or "%s"`, outputTable, outputJSON) + errInvalidOutput = fmt.Errorf("output %s", outputMessageHelp) +) + +func (o *cmdOutput) String() string { + return string(*o) +} + +func (o *cmdOutput) Set(v string) error { + switch v { + case string(outputTable), string(outputJSON): + *o = cmdOutput(v) + + return nil + default: + return errInvalidOutput + } +} + +func (o *cmdOutput) Type() string { + return "output" +} + +func outputCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{ + string(outputTable), + string(outputJSON), + }, cobra.ShellCompDirectiveDefault +} diff --git a/cmd/remove.go b/cmd/remove.go new file mode 100644 index 0000000..e54a986 --- /dev/null +++ b/cmd/remove.go @@ -0,0 +1,72 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "strings" + + "github.com/norbjd/pasuman/pkg/masterpassword" + "github.com/norbjd/pasuman/pkg/remove" + "github.com/norbjd/pasuman/pkg/util" + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove an entry", + Args: cobra.ExactArgs(1), + ValidArgsFunction: autocomplete, + RunE: func(cmd *cobra.Command, args []string) error { + masterPasswordSet, err := masterpassword.IsSet() + if err != nil { + return err + } + + if !masterPasswordSet { + return errNoMasterPasswordSet + } + + uniqueID := strings.TrimSpace(args[0]) + + cmdStderrPrintf(cmd, "Enter current master password: ") + masterPassword, err := util.ReadPassword() + if err != nil { + return err + } + + correct, err := masterpassword.IsCorrect(masterPassword) + if err != nil { + return err + } + + if !correct { + cmdStderrPrintln(cmd, "✘") + + return util.ErrMasterPasswordIncorrect + } + cmdStderrPrintln(cmd, "βœ”") + + if err := remove.Remove(uniqueID); err != nil { + return err + } + + cmdPrintf(cmd, "Removed entry: %s\n", uniqueID) + + return nil + }, +} diff --git a/cmd/remove_lock.go b/cmd/remove_lock.go new file mode 100644 index 0000000..674f47a --- /dev/null +++ b/cmd/remove_lock.go @@ -0,0 +1,48 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "errors" + "os" + + "github.com/norbjd/pasuman/pkg/config" + "github.com/spf13/cobra" +) + +var errNoLock = errors.New("no lock") + +var removeLockCmd = &cobra.Command{ + Use: "remove-lock", + Short: "Remove lock", + RunE: func(cmd *cobra.Command, args []string) error { + lockFile := config.PasumanDataFile + ".lock" + + err := os.Remove(lockFile) + if os.IsNotExist(err) { + return errNoLock + } + if err != nil { + return err + } + + cmdPrintln(cmd, "Lock has been removed!") + + return nil + }, +} diff --git a/cmd/remove_test.go b/cmd/remove_test.go new file mode 100644 index 0000000..b18dba9 --- /dev/null +++ b/cmd/remove_test.go @@ -0,0 +1,86 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/add" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/remove" + "github.com/stretchr/testify/require" +) + +func TestRemove(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + tests := []struct { + args []string + setup func() error + output string + wantErr error + }{ + { + args: []string{"remove", "does-not-exist"}, + wantErr: remove.ErrNotFound, + }, + { + args: []string{"remove", "id1"}, + setup: func() error { + _, err := add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id1", + Description: "A desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + }) + + return err + }, + output: "" + + "Enter current master password: βœ”\n" + + "Removed entry: id1\n", + }, + { + args: []string{"remove", "id1"}, + wantErr: remove.ErrNotFound, + }, + } + + for _, tt := range tests { + if tt.setup != nil { + require.NoError(t, tt.setup()) + } + + out, err := pasumantest.ExecuteCommand(RootCmd, tt.args...) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + + require.Equal(t, tt.output, out) + } + + pasumantest.Teardown(t, RootCmd) + } +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..4515b33 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,123 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "errors" + "fmt" + "log" + "os" + + "github.com/norbjd/pasuman/pkg/config" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/spf13/cobra" +) + +var ErrFileLocked = errors.New("file is locked") + +var rootCmdProfile string + +// nolint: gochecknoinits +func init() { + helpFunc := RootCmd.HelpFunc() + RootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if _, err := cmd.OutOrStdout().Write([]byte(Version() + "\n")); err != nil { + log.Fatal(err) + } + + helpFunc(cmd, args) + }) + + RootCmd.PersistentFlags().StringVar(&rootCmdProfile, "profile", constants.RootCmdDefaultProfile, "Profile") + + addCmdInit() + RootCmd.AddCommand(addCmd) + generateCmdInit() + RootCmd.AddCommand(generateCmd) + getCmdInit() + RootCmd.AddCommand(getCmd) + listCmdInit() + RootCmd.AddCommand(listCmd) + RootCmd.AddCommand(listProfilesCmd) + manCmdInit() + RootCmd.AddCommand(manCmd) + RootCmd.AddCommand(masterPasswordCmd) + RootCmd.AddCommand(removeCmd) + RootCmd.AddCommand(removeLockCmd) + searchCmdInit() + RootCmd.AddCommand(searchCmd) + updateCmdInit() + RootCmd.AddCommand(updateCmd) + + RootCmd.SetVersionTemplate(Version()) +} + +var RootCmd = &cobra.Command{ + Use: "pasuman", + Short: "A command-line password manager", + Version: "1.0.0", + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + + createDataFile := cmd == addCmd || cmd == masterPasswordCmd + config.Init(rootCmdProfile, createDataFile) + + if cmd == removeLockCmd || + cmd.Name() == cobra.ShellCompRequestCmd || cmd.Name() == cobra.ShellCompNoDescRequestCmd { + return nil + } + + lockFile := config.PasumanDataFile + ".lock" + + _, err := os.Stat(lockFile) + + if errors.Is(err, os.ErrNotExist) { + if _, err := os.Create(lockFile); err != nil { + return err + } + } else { + return fmt.Errorf("%w: use `pasuman remove-lock` command to remove it "+ + "(only if no other pasuman process is running!)", ErrFileLocked) + } + + return nil + }, + // caution: if the RunE fails, PersistentPostRun is not called + PersistentPostRunE: func(cmd *cobra.Command, _ []string) error { + if cmd.Name() == "__complete" { + return nil + } + + return RemoveLock() + }, +} + +// RemoveLock - removes the lock created by the `PersistentPreRunE` of the root command. +// We want to execute this even if the command fails (in the `RunE` part). +// If there is an error during RunE, PersistentPostRunE is not run. +// So as far as I know, we should also call `RemoveLock` manually in the `main.go`. +func RemoveLock() error { + lockFile := config.PasumanDataFile + ".lock" + + err := os.Remove(lockFile) + if !errors.Is(err, os.ErrNotExist) && err != nil { + return err + } + + return nil +} diff --git a/cmd/search.go b/cmd/search.go new file mode 100644 index 0000000..2b2c7ac --- /dev/null +++ b/cmd/search.go @@ -0,0 +1,70 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "fmt" + "log" + + "github.com/norbjd/pasuman/pkg/masterpassword" + "github.com/norbjd/pasuman/pkg/search" + "github.com/spf13/cobra" +) + +var searchCmd *cobra.Command + +var ( + searchCmdCaseSensitive bool + searchCmdOutput = outputTable +) + +func searchCmdInit() { + searchCmd = &cobra.Command{ + Use: "search ", + Short: "Search an entry by a term", + Args: cobra.ExactArgs(1), + RunE: searchCmdRunE, + } + + searchCmd.Flags().BoolVar(&searchCmdCaseSensitive, "case-sensitive", false, "Case-sensitive search") + searchCmd.Flags().Var(&searchCmdOutput, "output", fmt.Sprintf("Output format: %s", outputMessageHelp)) + + if err := searchCmd.RegisterFlagCompletionFunc("output", outputCompletion); err != nil { + log.Fatal(err) + } +} + +func searchCmdRunE(cmd *cobra.Command, args []string) error { + masterPasswordSet, err := masterpassword.IsSet() + if err != nil { + return err + } + + if !masterPasswordSet { + return errNoMasterPasswordSet + } + + term := args[0] + + entries, err := search.Search(rootCmdProfile, term, searchCmdCaseSensitive) + if err != nil { + return err + } + + return printEntries(searchCmd.OutOrStdout(), entries, searchCmdOutput) +} diff --git a/cmd/search_test.go b/cmd/search_test.go new file mode 100644 index 0000000..f52e98d --- /dev/null +++ b/cmd/search_test.go @@ -0,0 +1,182 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/add" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/data" + "github.com/stretchr/testify/require" +) + +func TestSearch(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + tests := []struct { + args []string + format cmdOutput + setup func() error + output string + wantErr error + }{ + { + args: []string{"search", "does-not-exist"}, + format: outputTable, + output: "Unique ID\tDescription\tTags\tSite\t\n---------\t-----------\t----\t----\t\n", + }, + { + args: []string{"search", "does-not-exist", "--output=json"}, + format: outputJSON, + output: "[]", + }, + { + args: []string{"search", "id"}, + format: outputTable, + setup: func() error { + _, err := add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id1", + Description: "A desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + }) + + return err + }, + output: "" + + "Unique ID\tDescription\tTags\t\tSite\t\t\t\t\n" + + "---------\t-----------\t----\t\t----\t\t\t\t\n" + + "id1\t\tA desc\t\ttag1,tag2\thttps://mysupersite.pasuman\t\n", + }, + { + args: []string{"search", "id", "--output=json"}, + format: outputJSON, + output: `[ + { + "unique_id": "id1", + "description": "A desc", + "tags": ["tag1", "tag2"], + "site": "https://mysupersite.pasuman" + } + ]`, + }, + { + args: []string{"search", "desc", "--output=json"}, + format: outputJSON, + output: `[ + { + "unique_id": "id1", + "description": "A desc", + "tags": ["tag1", "tag2"], + "site": "https://mysupersite.pasuman" + } + ]`, + }, + { + args: []string{"search", "tag2", "--output=json"}, + format: outputJSON, + output: `[ + { + "unique_id": "id1", + "description": "A desc", + "tags": ["tag1", "tag2"], + "site": "https://mysupersite.pasuman" + } + ]`, + }, + { + args: []string{"search", "supersite", "--output=json"}, + format: outputJSON, + output: `[ + { + "unique_id": "id1", + "description": "A desc", + "tags": ["tag1", "tag2"], + "site": "https://mysupersite.pasuman" + } + ]`, + }, + { + args: []string{"search", "myId", "--output=json"}, + format: outputJSON, + output: "[]", + }, + { + args: []string{"search", "id"}, + format: outputTable, + setup: func() error { + _, err := add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id2", + Description: "Another desc", + Tags: []string{"tag1", "tag3", "tag4"}, + Site: "https://anothersite.pasuman", + ID: "otherId", + Password: "t0ps3cr3t!", + }) + + return err + }, + output: "" + + "Unique ID\tDescription\tTags\t\tSite\t\t\t\t\n" + + "---------\t-----------\t----\t\t----\t\t\t\t\n" + + "id1\t\tA desc\t\ttag1,tag2\thttps://mysupersite.pasuman\t\n" + + "id2\t\tAnother desc\ttag1,tag3,tag4\thttps://anothersite.pasuman\t\n", + }, + { + args: []string{"search", "id", "--output=json"}, + format: outputJSON, + output: `[ + { + "unique_id": "id1", + "description": "A desc", + "tags": ["tag1", "tag2"], + "site": "https://mysupersite.pasuman" + }, + { + "unique_id": "id2", + "description": "Another desc", + "tags": ["tag1", "tag3", "tag4"], + "site": "https://anothersite.pasuman" + } + ]`, + }, + } + + for _, tt := range tests { + if tt.setup != nil { + require.NoError(t, tt.setup()) + } + + out, err := pasumantest.ExecuteCommand(RootCmd, tt.args...) + require.NoError(t, err) + + if tt.format == outputJSON { + require.JSONEq(t, tt.output, out) + } else { + require.Equal(t, tt.output, out) + } + + pasumantest.Teardown(t, RootCmd) + } +} diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..e1915bb --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,169 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "errors" + "strings" + + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/get" + "github.com/norbjd/pasuman/pkg/masterpassword" + "github.com/norbjd/pasuman/pkg/update" + "github.com/norbjd/pasuman/pkg/util" + "github.com/spf13/cobra" +) + +var ( + updateCmdUniqueID string + updateCmdDescription string + updateCmdTags []string + updateCmdSite string + updateCmdID string + updateCmdPassword string +) + +func updateCmdInit() { + updateCmd.Flags().StringVar(&updateCmdUniqueID, "unique-id", "", "Unique ID") + updateCmd.Flags().StringVar(&updateCmdDescription, "description", "", "Description") + updateCmd.Flags().StringSliceVar(&updateCmdTags, "tags", []string{}, "Tags") + updateCmd.Flags().StringVar(&updateCmdSite, "site", "", "Site") + updateCmd.Flags().StringVar(&updateCmdID, "id", "", "ID") + updateCmd.Flags().StringVar(&updateCmdPassword, "password", "", "Password") +} + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update an entry", + Args: cobra.ExactArgs(1), + ValidArgsFunction: autocomplete, + RunE: func(cmd *cobra.Command, args []string) error { + masterPasswordSet, err := masterpassword.IsSet() + if err != nil { + return err + } + + if !masterPasswordSet { + return errNoMasterPasswordSet + } + + uniqueID := strings.TrimSpace(args[0]) + + if _, err := get.NotSensitive(uniqueID); errors.Is(err, get.ErrNotFound) { + return update.ErrNotFound + } + + cmdStderrPrintf(cmd, "Enter current master password: ") + masterPassword, err := util.ReadPassword() + if err != nil { + return err + } + + correct, err := masterpassword.IsCorrect(masterPassword) + if err != nil { + return err + } + + if !correct { + cmdStderrPrintln(cmd, "✘") + + return util.ErrMasterPasswordIncorrect + } + cmdStderrPrintln(cmd, "βœ”") + + if updateCmdDescription == "" && len(updateCmdTags) == 0 && updateCmdSite == "" && + updateCmdID == "" && updateCmdPassword == "" { + cmdPrintln(cmd, "INFO: Leave field empty if you don't want to update it") + + if updateCmdUniqueID == "" { + cmdPrintf(cmd, "Enter unique id: ") + updateCmdUniqueID, err = util.ReadLine() + if err != nil { + return err + } + updateCmdUniqueID = strings.TrimSpace(updateCmdUniqueID) + } + if updateCmdDescription == "" { + cmdPrintf(cmd, "Enter description: ") + updateCmdDescription, err = util.ReadLine() + if err != nil { + return err + } + updateCmdDescription = strings.TrimSpace(updateCmdDescription) + } + if len(updateCmdTags) == 0 { + cmdPrintf(cmd, "Enter tags (comma-separated): ") + tagsCommaSeparated, err := util.ReadLine() + if err != nil { + return err + } + tagsCommaSeparatedTrimmed := strings.TrimSpace(tagsCommaSeparated) + if tagsCommaSeparatedTrimmed != "" { + updateCmdTags = strings.Split(tagsCommaSeparatedTrimmed, ",") + } + } + if updateCmdSite == "" { + cmdPrintf(cmd, "Enter site: ") + updateCmdSite, err = util.ReadLine() + if err != nil { + return err + } + updateCmdSite = strings.TrimSpace(updateCmdSite) + } + if updateCmdID == "" { + cmdPrintf(cmd, "Enter id: ") + updateCmdID, err = util.ReadLine() + if err != nil { + return err + } + updateCmdID = strings.TrimSpace(updateCmdID) + } + if updateCmdPassword == "" { + cmdPrintf(cmd, "Enter password: ") + updateCmdPassword, err = util.ReadPassword() + if err != nil { + return err + } + + cmdPrintln(cmd, "βœ”") + } + } + + if err := update.Update( + masterPassword, + uniqueID, + data.Entry{ + UniqueID: updateCmdUniqueID, + Description: updateCmdDescription, + Tags: updateCmdTags, + Site: updateCmdSite, + ID: updateCmdID, + Password: updateCmdPassword, + }, + ); err != nil { + return err + } + + if updateCmdUniqueID != "" { + uniqueID = updateCmdUniqueID + } + cmdPrintf(cmd, "Updated: %s\n", uniqueID) + + return nil + }, +} diff --git a/cmd/update_test.go b/cmd/update_test.go new file mode 100644 index 0000000..e87245d --- /dev/null +++ b/cmd/update_test.go @@ -0,0 +1,237 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/add" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/get" + "github.com/norbjd/pasuman/pkg/update" + "github.com/stretchr/testify/require" +) + +func TestUpdate(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + tests := []struct { + args []string + setup func() error + check func() + output string + wantErr error + }{ + { + args: []string{"update", "does-not-exist"}, + wantErr: update.ErrNotFound, + }, + { + args: []string{"update", "id1", "--description=New desc"}, + setup: func() error { + _, err := add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id1", + Description: "A desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + }) + + return err + }, + check: func() { + t.Helper() + + entry, err := get.Sensitive(pasumantest.TestMasterPassword, "id1") + require.NoError(t, err) + + require.Equal(t, data.Entry{ + UniqueID: "id1", + Description: "New desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + }, entry) + }, + output: "" + + "Enter current master password: βœ”\n" + + "Updated: id1\n", + }, + { + args: []string{"update", "id1", "--tags=tag2,tag3"}, + check: func() { + t.Helper() + + entry, err := get.Sensitive(pasumantest.TestMasterPassword, "id1") + require.NoError(t, err) + + require.Equal(t, data.Entry{ + UniqueID: "id1", + Description: "New desc", + Tags: []string{"tag2", "tag3"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + }, entry) + }, + output: "" + + "Enter current master password: βœ”\n" + + "Updated: id1\n", + }, + { + args: []string{"update", "id1", "--site=https://mynewsite.pasuman"}, + check: func() { + t.Helper() + + entry, err := get.Sensitive(pasumantest.TestMasterPassword, "id1") + require.NoError(t, err) + + require.Equal(t, data.Entry{ + UniqueID: "id1", + Description: "New desc", + Tags: []string{"tag2", "tag3"}, + Site: "https://mynewsite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + }, entry) + }, + output: "" + + "Enter current master password: βœ”\n" + + "Updated: id1\n", + }, + { + args: []string{"update", "id1", "--id=newId"}, + check: func() { + t.Helper() + + entry, err := get.Sensitive(pasumantest.TestMasterPassword, "id1") + require.NoError(t, err) + + require.Equal(t, data.Entry{ + UniqueID: "id1", + Description: "New desc", + Tags: []string{"tag2", "tag3"}, + Site: "https://mynewsite.pasuman", + ID: "newId", + Password: "p4$$w0rd!", + }, entry) + }, + output: "" + + "Enter current master password: βœ”\n" + + "Updated: id1\n", + }, + { + args: []string{"update", "id1", "--password=n€wp4$$w0rd!"}, + check: func() { + t.Helper() + + entry, err := get.Sensitive(pasumantest.TestMasterPassword, "id1") + require.NoError(t, err) + + require.Equal(t, data.Entry{ + UniqueID: "id1", + Description: "New desc", + Tags: []string{"tag2", "tag3"}, + Site: "https://mynewsite.pasuman", + ID: "newId", + Password: "n€wp4$$w0rd!", + }, entry) + }, + output: "" + + "Enter current master password: βœ”\n" + + "Updated: id1\n", + }, + { + args: []string{"update", "id1", "--unique-id=newId1"}, + check: func() { + t.Helper() + + _, err := get.Sensitive(pasumantest.TestMasterPassword, "id1") + require.ErrorIs(t, err, get.ErrNotFound) + + entry, err := get.Sensitive(pasumantest.TestMasterPassword, "newId1") + require.NoError(t, err) + + require.Equal(t, data.Entry{ + UniqueID: "newId1", + Description: "New desc", + Tags: []string{"tag2", "tag3"}, + Site: "https://mynewsite.pasuman", + ID: "newId", + Password: "n€wp4$$w0rd!", + }, entry) + }, + output: "" + + "Enter current master password: βœ”\n" + + "Updated: newId1\n", + }, + { + args: []string{ + "update", "newId1", "--unique-id=brandNewId1", "--description=Brand new desc", + "--site=https://brandnewsite.pasuman", "--id=brandNewId", "--password=br4ndn€wp4$$w0rd!", + }, + check: func() { + t.Helper() + + _, err := get.Sensitive(pasumantest.TestMasterPassword, "newId1") + require.ErrorIs(t, err, get.ErrNotFound) + + entry, err := get.Sensitive(pasumantest.TestMasterPassword, "brandNewId1") + require.NoError(t, err) + + require.Equal(t, data.Entry{ + UniqueID: "brandNewId1", + Description: "Brand new desc", + Tags: []string{"tag2", "tag3"}, + Site: "https://brandnewsite.pasuman", + ID: "brandNewId", + Password: "br4ndn€wp4$$w0rd!", + }, entry) + }, + output: "" + + "Enter current master password: βœ”\n" + + "Updated: brandNewId1\n", + }, + } + + for _, tt := range tests { + if tt.setup != nil { + require.NoError(t, tt.setup()) + } + + out, err := pasumantest.ExecuteCommand(RootCmd, tt.args...) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tt.output, out) + } + + if tt.check != nil { + tt.check() + } + + pasumantest.Teardown(t, RootCmd) + } +} diff --git a/cmd/util.go b/cmd/util.go new file mode 100644 index 0000000..7701fba --- /dev/null +++ b/cmd/util.go @@ -0,0 +1,105 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/list" + "github.com/norbjd/pasuman/pkg/util" + "github.com/spf13/cobra" +) + +func cmdPrintf(cmd *cobra.Command, format string, a ...interface{}) { + fmt.Fprintf(cmd.OutOrStdout(), format, a...) +} + +func cmdStderrPrintf(cmd *cobra.Command, format string, a ...interface{}) { + fmt.Fprintf(cmd.ErrOrStderr(), format, a...) +} + +func cmdPrintln(cmd *cobra.Command, message string) { + cmdPrintf(cmd, "%s\n", message) +} + +func cmdStderrPrintln(cmd *cobra.Command, message string) { + cmdStderrPrintf(cmd, "%s\n", message) +} + +func printEntries(w io.Writer, entries []data.Entry, output cmdOutput) error { + switch output { + case outputTable: + headerColumns := []string{"Unique ID", "Description", "Tags", "Site"} + + var lines [][]string + + for _, entry := range entries { + columns := []string{entry.UniqueID, entry.Description, strings.Join(entry.Tags, ","), entry.Site} + + lines = append(lines, columns) + } + + util.RenderTable(w, headerColumns, lines) + case outputJSON: + type jsonEntry struct { + UniqueID string `json:"unique_id"` + Description string `json:"description"` + Tags []string `json:"tags"` + Site string `json:"site"` + } + + jsonEntries := make([]jsonEntry, len(entries)) + + for idx, e := range entries { + jsonEntries[idx] = jsonEntry{UniqueID: e.UniqueID, Description: e.Description, Tags: e.Tags, Site: e.Site} + } + + result, err := json.MarshalIndent(jsonEntries, "", " ") + if err != nil { + return err + } + + fmt.Fprintln(w, string(result)) + default: + return errInvalidOutput + } + + return nil +} + +func autocomplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + listResult, err := list.List(rootCmdProfile) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + uniqueIDs := make([]string, len(listResult)) + for idx := range listResult { + uniqueIDs[idx] = listResult[idx].UniqueID + } + + return uniqueIDs, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..73e222f --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,45 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cmd + +import ( + "fmt" +) + +var ( + GitVersion = "unknown" + GitCommit = "unknown" + BuildDate = "unknown" +) + +func Version() string { + message := "pasuman" + + if GitVersion != "unknown" && GitVersion != "" { + message += fmt.Sprintf(" %s -", GitVersion) + } + + message += fmt.Sprintf(" %s - built %s\n\n", GitCommit, BuildDate) + + message += "Copyright (C) 2022 norbjd\n" + + "License GPLv3: GNU GPL version 3 \n" + + "This is free software: you are free to change and redistribute it.\n" + + "There is NO WARRANTY, to the extent permitted by law.\n" + + return message +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7d376eb --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (c) 2022 norbjd +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +module github.com/norbjd/pasuman + +go 1.18 + +require ( + github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 + github.com/spf13/cobra v1.5.0 + golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d + golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 +) + +// tests +require ( + github.com/google/uuid v1.3.0 + github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.7.5 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + golang.org/x/sys v0.0.0-20220702020025-31831981b65f // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c5211ef --- /dev/null +++ b/go.sum @@ -0,0 +1,45 @@ +github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 h1:loy0fjI90vF44BPW4ZYOkE3tDkGTy7yHURusOJimt+I= +github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387/go.mod h1:GuR5j/NW7AU7tDAQUDGCtpiPxWIOy/c3kiRDnlwiCHc= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= +github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= +github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220702020025-31831981b65f h1:xdsejrW/0Wf2diT5CPp3XmKUNbr7Xvw8kYilQ+6qjRY= +golang.org/x/sys v0.0.0-20220702020025-31831981b65f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/pkg/pasumantest/util.go b/internal/pkg/pasumantest/util.go new file mode 100644 index 0000000..a0a983d --- /dev/null +++ b/internal/pkg/pasumantest/util.go @@ -0,0 +1,145 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package pasumantest + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/alexedwards/argon2id" + "github.com/norbjd/pasuman/pkg/config" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" +) + +const ( + defaultDirMode = os.FileMode(0o700) + defaultFileMode = os.FileMode(0o600) + + TestMasterPassword = "pass" +) + +func Init(t *testing.T, profile string) string { + t.Helper() + + var err error + tempDir, err := os.MkdirTemp(os.TempDir(), t.Name()) + require.NoError(t, err) + + config.ConfigDir = tempDir + string(os.PathSeparator) + ".config" + err = os.Mkdir(config.ConfigDir, defaultDirMode) + require.NoError(t, err) + + dataDir := tempDir + string(os.PathSeparator) + "data" + err = os.Mkdir(dataDir, defaultDirMode) + require.NoError(t, err) + + err = os.Mkdir(config.ConfigDir+string(os.PathSeparator)+"pasuman", defaultDirMode) + require.NoError(t, err) + + err = os.WriteFile(config.ConfigDir+string(os.PathSeparator)+"pasuman"+string(os.PathSeparator)+"config.json", + []byte(`{"data_directory": "`+dataDir+`"}`), defaultFileMode) + require.NoError(t, err) + + config.Init(profile, true) + require.NoError(t, InitProfile(profile)) + + return tempDir +} + +func InitProfile(profile string) error { + masterPasswordHash, err := argon2id.CreateHash(TestMasterPassword, argon2id.DefaultParams) + if err != nil { + return err + } + + err = os.Setenv("PASUMAN_MASTER_PASSWORD", TestMasterPassword) + if err != nil { + return err + } + + return os.WriteFile(config.PasumanDataFile, []byte(`{"master_password":"`+masterPasswordHash+`"}`), 0) +} + +func ExecuteCommand(c *cobra.Command, args ...string) (string, error) { + buf := new(bytes.Buffer) + + c.SetOut(buf) + c.SetErr(buf) + c.SetArgs(args) + + err := c.Execute() + + return buf.String(), err +} + +// Teardown - reset flags to default value +// and run all "PostRun" functions. +// For some reason, `f.Value.Set(f.DefValue)` does not work +// if the value is of type slice and default value is nil +// because if so, f.DefValue is the string "[]". +func Teardown(t *testing.T, c *cobra.Command) { + t.Helper() + + c.Flags().VisitAll(func(f *pflag.Flag) { + var err error + + if strings.HasSuffix(f.Value.Type(), "Slice") { + err = f.Value.Set("") + } else { + err = f.Value.Set(f.DefValue) + } + + require.NoError(t, err) + }) + + for _, subCommand := range c.Commands() { + subCommand.Flags().VisitAll(func(f *pflag.Flag) { + var err error + + if strings.HasSuffix(f.Value.Type(), "Slice") { + err = f.Value.Set("") + } else { + err = f.Value.Set(f.DefValue) + } + + require.NoError(t, err) + }) + } + + if c.PostRun != nil { + c.PostRun(c, nil) + } + + if c.PostRunE != nil { + err := c.PostRunE(c, nil) + require.NoError(t, err) + } + + if c.PersistentPostRun != nil { + c.PersistentPostRun(c, nil) + } + + if c.PersistentPostRunE != nil { + err := c.PersistentPostRunE(c, nil) + require.NoError(t, err) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f628dda --- /dev/null +++ b/main.go @@ -0,0 +1,51 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package main + +import ( + "errors" + "log" + "os" + + "github.com/norbjd/pasuman/cmd" + "github.com/spf13/cobra" +) + +func main() { + errExecute := cmd.RootCmd.Execute() + if errors.Is(errExecute, cmd.ErrFileLocked) { + os.Exit(1) + } + + if cmd.RootCmd.Short == "remove-lock" { + return + } + + if cmd.RootCmd.Short == cobra.ShellCompRequestCmd || cmd.RootCmd.Short == cobra.ShellCompNoDescRequestCmd { + return + } + + errRemoveLock := cmd.RemoveLock() + if errRemoveLock != nil { + log.Fatal(errRemoveLock) + } + + if errExecute != nil { + os.Exit(1) + } +} diff --git a/pkg/add/add.go b/pkg/add/add.go new file mode 100644 index 0000000..2d065d4 --- /dev/null +++ b/pkg/add/add.go @@ -0,0 +1,53 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package add + +import ( + "github.com/norbjd/pasuman/pkg/config" + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/encrypt" + "github.com/norbjd/pasuman/pkg/util" +) + +func Add(masterPassword string, e data.Entry) (string, error) { + var d data.Data + + if err := d.FromFile(config.PasumanDataFile); err != nil { + return "", err + } + + var err error + + if e.UniqueID == "" { + if e.UniqueID, err = util.NewUUIDV4(); err != nil { + return "", err + } + } + + if e.ID, err = encrypt.Encrypt(masterPassword, e.ID); err != nil { + return "", err + } + + if e.Password, err = encrypt.Encrypt(masterPassword, e.Password); err != nil { + return "", err + } + + d.Entries = append(d.Entries, e) + + return e.UniqueID, d.ToFile(config.PasumanDataFile) +} diff --git a/pkg/add/add_test.go b/pkg/add/add_test.go new file mode 100644 index 0000000..d611f05 --- /dev/null +++ b/pkg/add/add_test.go @@ -0,0 +1,125 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package add + +import ( + "os" + "regexp" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/config" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/encrypt" + "github.com/norbjd/pasuman/pkg/util" + "github.com/stretchr/testify/require" +) + +func TestAdd(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + type args struct { + masterPassword string + e data.Entry + } + + tests := []struct { + args args + want string + check func(s string) + wantErr error + }{ + { + args: args{ + masterPassword: "", + e: data.Entry{}, + }, + wantErr: util.ErrMasterPasswordMustNotBeEmpty, + }, + { + args: args{ + masterPassword: "pass", + e: data.Entry{}, + }, + check: func(s string) { + regexUUID := regexp.MustCompile("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-" + + "[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + require.Regexp(t, regexUUID, s) + }, + }, + { + args: args{ + masterPassword: "pass", + e: data.Entry{ + UniqueID: "my-unique-id", + }, + }, + want: "my-unique-id", + }, + { + args: args{ + masterPassword: "pass", + e: data.Entry{ + UniqueID: "new-unique-id", + ID: "my-id", + Password: "p4$$w0rd!", + }, + }, + want: "new-unique-id", + check: func(s string) { + var d data.Data + + err := d.FromFile(config.PasumanDataFile) + require.NoError(t, err) + + for _, entry := range d.Entries { + if entry.UniqueID == "new-unique-id" { + id, err := encrypt.Decrypt("pass", entry.ID) + require.NoError(t, err) + require.Equal(t, "my-id", id) + + password, err := encrypt.Decrypt("pass", entry.Password) + require.NoError(t, err) + require.Equal(t, "p4$$w0rd!", password) + + return + } + } + + t.Fail() + }, + }, + } + + for _, tt := range tests { + got, err := Add(tt.args.masterPassword, tt.args.e) + + if err != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + if tt.want != "" { + require.Equal(t, tt.want, got) + } + if tt.check != nil { + tt.check(got) + } + } + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..8589b0c --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,182 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "log" + "os" + "strings" + + "github.com/norbjd/pasuman/pkg/util" +) + +const ( + defaultDirMode = os.FileMode(0o700) + defaultFileMode = os.FileMode(0o600) +) + +var ( + ConfigDir string + PasumanConfigFile string + PasumanDataFile string +) + +var errCannotInitConfigFile = errors.New("cannot init config file") + +func Init(profile string, createDataFile bool) { + if ConfigDir == "" { + configDir, err := os.UserConfigDir() + if err != nil { + log.Fatal("No config dir") + } + + ConfigDir = configDir + } + + pasumanConfigDir := ConfigDir + string(os.PathSeparator) + "pasuman" + PasumanConfigFile = pasumanConfigDir + string(os.PathSeparator) + "config.json" + + config := initConfigFileIfNecessary(pasumanConfigDir) + initDataFile(config, profile, createDataFile) +} + +type Config struct { + DataDirectory string `json:"data_directory"` +} + +func GetConfig() Config { + pasumanConfigFileHandler, err := os.Open(PasumanConfigFile) + if err != nil { + log.Fatal(err) + } + + var config Config + + jsonDecoder := json.NewDecoder(pasumanConfigFileHandler) + if err = jsonDecoder.Decode(&config); err != nil { + log.Fatal(err) + } + + return config +} + +func GetDataFile(config Config, profile string) string { + return fmt.Sprintf("%s%s%s.json", config.DataDirectory, string(os.PathSeparator), profile) +} + +func initConfigFileIfNecessary(pasumanConfigDir string) Config { + dirInfo, err := os.Stat(pasumanConfigDir) + if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(pasumanConfigDir, defaultDirMode); err != nil { + log.Fatal(err) + } + + dirInfo, err = os.Stat(pasumanConfigDir) + } + + if err != nil { + log.Fatal(err) + } + + if !dirInfo.IsDir() { + log.Fatalf("%s should be a directory", pasumanConfigDir) + } + + fileInfo, err := os.Stat(PasumanConfigFile) + + if errors.Is(err, os.ErrNotExist) { + fileInfo, err = initPasuman() + } + + if err != nil { + log.Fatal(err) + } + + if fileInfo.IsDir() { + log.Fatalf("%s should be a file, not a directory", PasumanConfigFile) + } + + config := GetConfig() + + if _, err := os.Stat(config.DataDirectory); errors.Is(err, os.ErrNotExist) { + writeErr := os.MkdirAll(config.DataDirectory, defaultDirMode) + if writeErr != nil { + log.Fatal("Cannot create data directory") + } + } + + return config +} + +func initDataFile(config Config, profile string, create bool) { + PasumanDataFile = GetDataFile(config, profile) + + if create { + if _, err := os.Stat(PasumanDataFile); errors.Is(err, os.ErrNotExist) { + writeErr := os.WriteFile(PasumanDataFile, []byte("{}"), defaultFileMode) + if writeErr != nil { + log.Fatalf("Cannot init profile %s file", profile) + } + } + } +} + +func initPasuman() (fs.FileInfo, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + defaultDataDirectory := homeDir + string(os.PathSeparator) + ".pasuman" + + fmt.Printf("Enter data directory (leave empty for default: %s): ", defaultDataDirectory) + + dataDirectory, err := util.ReadLine() + if err != nil { + return nil, err + } + + dataDirectory = strings.TrimSpace(dataDirectory) + + if dataDirectory == "" { + dataDirectory = defaultDataDirectory + } + + config := Config{DataDirectory: dataDirectory} + + configJSON, err := json.MarshalIndent(config, "", " ") + if err != nil { + return nil, err + } + + writeErr := os.WriteFile(PasumanConfigFile, configJSON, defaultFileMode) + if writeErr != nil { + return nil, errCannotInitConfigFile + } + + fileInfo, err := os.Stat(PasumanConfigFile) + if err != nil { + return nil, err + } + + return fileInfo, err +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go new file mode 100644 index 0000000..5474013 --- /dev/null +++ b/pkg/constants/constants.go @@ -0,0 +1,24 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package constants + +const ( + RootCmdDefaultProfile = "default" + + Alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" +) diff --git a/pkg/data/data.go b/pkg/data/data.go new file mode 100644 index 0000000..51252a7 --- /dev/null +++ b/pkg/data/data.go @@ -0,0 +1,55 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package data + +import ( + "encoding/json" + "os" +) + +type Data struct { + MasterPassword string `json:"master_password"` + Entries []Entry `json:"entries"` +} + +type Entry struct { + UniqueID string `json:"unique_id"` + Description string `json:"description"` + Tags []string `json:"tags"` + Site string `json:"site"` + ID string `json:"id"` + Password string `json:"password"` +} + +func (data *Data) FromFile(file string) error { + byteContents, err := os.ReadFile(file) + if err != nil { + return err + } + + return json.Unmarshal(byteContents, data) +} + +func (data *Data) ToFile(file string) error { + byteContents, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + + return os.WriteFile(file, byteContents, os.ModeAppend) +} diff --git a/pkg/encrypt/encrypt.go b/pkg/encrypt/encrypt.go new file mode 100644 index 0000000..2f0ee80 --- /dev/null +++ b/pkg/encrypt/encrypt.go @@ -0,0 +1,181 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package encrypt + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "math/big" + "strings" + + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/util" + "golang.org/x/crypto/argon2" +) + +const ( + encryptedMessageStringSeparator = "*" + encryptedMessageSplitStringLength = 3 + + saltLength = 64 + nonceLength = 12 + + argon2Time = 16 + argon2Memory = 256 * 1024 + argon2Threads = 4 + argon2KeyLength = 32 + + encryptedStringWithPaddingLength = 512 + paddingSeparator = '\x00' +) + +var errEncryptedMessageStringInvalid = errors.New("encrypted message string is invalid") + +type EncryptedMessage struct { + base64Salt string + base64Nonce string + base64Message string +} + +func (e *EncryptedMessage) String() string { + return fmt.Sprintf("%s%s%s%s%s", + e.base64Salt, encryptedMessageStringSeparator, + e.base64Nonce, encryptedMessageStringSeparator, + e.base64Message) +} + +func (e *EncryptedMessage) FromString(s string) error { + split := strings.Split(s, encryptedMessageStringSeparator) + if len(split) != encryptedMessageSplitStringLength { + return fmt.Errorf("%w: %s", errEncryptedMessageStringInvalid, s) + } + + e.base64Salt = split[0] + e.base64Nonce = split[1] + e.base64Message = split[2] + + return nil +} + +func Encrypt(masterPassword, stringToEncrypt string) (string, error) { + if masterPassword == "" { + return "", util.ErrMasterPasswordMustNotBeEmpty + } + + salt := make([]byte, saltLength) + if _, err := rand.Read(salt); err != nil { + return "", err + } + + key := argon2.IDKey([]byte(masterPassword), salt, + argon2Time, argon2Memory, argon2Threads, argon2KeyLength) + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + rightPaddingLength := encryptedStringWithPaddingLength - len(stringToEncrypt) + + if rightPaddingLength > 0 { + rightPadding := make([]byte, rightPaddingLength) + rightPadding[0] = paddingSeparator + + for i := 1; i < rightPaddingLength; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(constants.Alphabet)))) + if err != nil { + return "", err + } + + rightPadding[i] = constants.Alphabet[num.Int64()] + } + + stringToEncrypt += string(rightPadding) + } + + plaintext := []byte(stringToEncrypt) + + nonce := make([]byte, nonceLength) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := aesgcm.Seal(nil, nonce, plaintext, nil) + + encryptedMessage := EncryptedMessage{ + base64Salt: base64.StdEncoding.EncodeToString(salt), + base64Nonce: base64.StdEncoding.EncodeToString(nonce), + base64Message: base64.StdEncoding.EncodeToString(ciphertext), + } + + return encryptedMessage.String(), nil +} + +func Decrypt(masterPassword, stringToDecrypt string) (string, error) { + var encryptedMessage EncryptedMessage + if err := encryptedMessage.FromString(stringToDecrypt); err != nil { + return "", err + } + + salt, err := base64.StdEncoding.DecodeString(encryptedMessage.base64Salt) + if err != nil { + return "", err + } + + key := argon2.IDKey([]byte(masterPassword), salt, + argon2Time, argon2Memory, argon2Threads, argon2KeyLength) + + ciphertext, err := base64.StdEncoding.DecodeString(encryptedMessage.base64Message) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce, err := base64.StdEncoding.DecodeString(encryptedMessage.base64Nonce) + if err != nil { + return "", err + } + + plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + decrypted := strings.Split(string(plaintext), string(paddingSeparator))[0] + + return decrypted, nil +} diff --git a/pkg/encrypt/encrypt_test.go b/pkg/encrypt/encrypt_test.go new file mode 100644 index 0000000..afc1509 --- /dev/null +++ b/pkg/encrypt/encrypt_test.go @@ -0,0 +1,92 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package encrypt + +import ( + "strings" + "testing" + + "github.com/norbjd/pasuman/pkg/util" + "github.com/stretchr/testify/require" +) + +func TestEncryptDecrypt(t *testing.T) { + tests := []struct { + masterPassword string + stringToEncrypt string + wantErr error + }{ + { + masterPassword: "", + stringToEncrypt: "p4$$w0rd!", + wantErr: util.ErrMasterPasswordMustNotBeEmpty, + }, + { + masterPassword: "pass", + stringToEncrypt: "", + }, + { + masterPassword: "pass", + stringToEncrypt: "p4$$w0rd!", + }, + { + masterPassword: "pass", + stringToEncrypt: "パスワード", + }, + { + masterPassword: "pass", + // 513 bytes (1 character = 1 byte) + stringToEncrypt: "verylongpassword/verylongpassword/verylongpassword/verylongpassword/" + + "verylongpassword/verylongpassword/verylongpassword/verylongpassword/verylongpassword/" + + "verylongpassword/verylongpassword/verylongpassword/verylongpassword/verylongpassword/" + + "verylongpassword/verylongpassword/verylongpassword/verylongpassword/verylongpassword/" + + "verylongpassword/verylongpassword/verylongpassword/verylongpassword/verylongpassword/" + + "verylongpassword/verylongpassword/verylongpassword/verylongpassword/verylongpassword/" + + "verylongpassword/ver", + }, + { + masterPassword: "pass", + // 513 bytes (1 character = 3 bytes) + stringToEncrypt: "パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・" + + "パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・" + + "パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・パスワード・パスワ", + }, + } + + for _, tt := range tests { + got, err := Encrypt(tt.masterPassword, tt.stringToEncrypt) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + + continue + } + + if err != nil { + t.Fatal(err) + } + + // if password length <= 512 bytes, encrypted string have length 810 + // due to padding + require.GreaterOrEqual(t, len(got), 810) + require.Len(t, strings.Split(got, encryptedMessageStringSeparator), encryptedMessageSplitStringLength) + + decrypted, err := Decrypt("pass", got) + require.NoError(t, err) + require.Equal(t, tt.stringToEncrypt, decrypted) + } +} diff --git a/pkg/generate/generate.go b/pkg/generate/generate.go new file mode 100644 index 0000000..b11df19 --- /dev/null +++ b/pkg/generate/generate.go @@ -0,0 +1,40 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package generate + +import ( + "crypto/rand" + "math/big" + + "github.com/norbjd/pasuman/pkg/constants" +) + +func Generate(length uint) (string, error) { + generated := make([]byte, length) + + for i := uint(0); i < length; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(constants.Alphabet)))) + if err != nil { + return "", err + } + + generated[i] = constants.Alphabet[num.Int64()] + } + + return string(generated), nil +} diff --git a/pkg/generate/generate_test.go b/pkg/generate/generate_test.go new file mode 100644 index 0000000..04ed85d --- /dev/null +++ b/pkg/generate/generate_test.go @@ -0,0 +1,52 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package generate + +import ( + "strings" + "testing" + + "github.com/norbjd/pasuman/pkg/constants" + "github.com/stretchr/testify/require" +) + +func TestGenerate(t *testing.T) { + tests := []struct { + length uint + }{ + {length: 0}, + {length: 10}, + {length: 128}, + {length: 256}, + } + + for _, tt := range tests { + got, err := Generate(tt.length) + require.NoError(t, err) + + require.Len(t, got, int(tt.length)) + + for _, r := range got { + if !strings.ContainsRune(constants.Alphabet, r) { + t.Fatalf("Password contains character '%s' but should not", string(r)) + + break + } + } + } +} diff --git a/pkg/get/get.go b/pkg/get/get.go new file mode 100644 index 0000000..f0abf15 --- /dev/null +++ b/pkg/get/get.go @@ -0,0 +1,83 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package get + +import ( + "errors" + + "github.com/norbjd/pasuman/pkg/config" + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/encrypt" +) + +var ErrNotFound = errors.New("entry not found") + +func getEntry(uniqueID string) (data.Entry, error) { + var d data.Data + + if err := d.FromFile(config.PasumanDataFile); err != nil { + return data.Entry{}, err + } + + var entry data.Entry + + for _, e := range d.Entries { + if e.UniqueID == uniqueID { + entry = e + + break + } + } + + if entry.UniqueID == "" { + return data.Entry{}, ErrNotFound + } + + return entry, nil +} + +func NotSensitive(uniqueID string) (data.Entry, error) { + entry, err := getEntry(uniqueID) + if err != nil { + return data.Entry{}, err + } + + entry.ID = "" + entry.Password = "" + + return entry, nil +} + +func Sensitive(masterPassword string, uniqueID string) (data.Entry, error) { + entry, err := getEntry(uniqueID) + if err != nil { + return data.Entry{}, err + } + + entry.ID, err = encrypt.Decrypt(masterPassword, entry.ID) + if err != nil { + return data.Entry{}, err + } + + entry.Password, err = encrypt.Decrypt(masterPassword, entry.Password) + if err != nil { + return data.Entry{}, err + } + + return entry, nil +} diff --git a/pkg/get/get_test.go b/pkg/get/get_test.go new file mode 100644 index 0000000..a7b5c6a --- /dev/null +++ b/pkg/get/get_test.go @@ -0,0 +1,151 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package get + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/add" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/data" + "github.com/stretchr/testify/require" +) + +func TestNotSensitive(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + entry1 := data.Entry{ + UniqueID: "id1", + Description: "A desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + } + _, err := add.Add(pasumantest.TestMasterPassword, entry1) + require.NoError(t, err) + + entry2 := data.Entry{ + UniqueID: "id2", + Description: "Another desc", + Tags: []string{"tag1", "tag3", "tag4"}, + Site: "https://anothersite.pasuman", + ID: "otherId", + Password: "t0ps3cr3t!", + } + _, err = add.Add(pasumantest.TestMasterPassword, entry2) + require.NoError(t, err) + + tests := []struct { + uniqueID string + want data.Entry + wantErr error + }{ + { + uniqueID: "id1", + want: entry1, + }, + { + uniqueID: "id2", + want: entry2, + }, + { + uniqueID: "", + wantErr: ErrNotFound, + }, + { + uniqueID: "id3", + wantErr: ErrNotFound, + }, + } + + for _, tt := range tests { + got, err := NotSensitive(tt.uniqueID) + + if err != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + tt.want.ID = "" + tt.want.Password = "" + require.Equal(t, tt.want, got) + } + } +} + +func TestSensitive(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + entry1 := data.Entry{ + UniqueID: "id1", + Description: "A desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + } + _, err := add.Add(pasumantest.TestMasterPassword, entry1) + require.NoError(t, err) + + entry2 := data.Entry{ + UniqueID: "id2", + Description: "Another desc", + Tags: []string{"tag1", "tag3", "tag4"}, + Site: "https://anothersite.pasuman", + ID: "otherId", + Password: "t0ps3cr3t!", + } + _, err = add.Add(pasumantest.TestMasterPassword, entry2) + require.NoError(t, err) + + tests := []struct { + uniqueID string + want data.Entry + wantErr error + }{ + { + uniqueID: "id1", + want: entry1, + }, + { + uniqueID: "id2", + want: entry2, + }, + { + uniqueID: "", + wantErr: ErrNotFound, + }, + { + uniqueID: "id3", + wantErr: ErrNotFound, + }, + } + + for _, tt := range tests { + got, err := Sensitive(pasumantest.TestMasterPassword, tt.uniqueID) + + if err != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.Equal(t, tt.want, got) + } + } +} diff --git a/pkg/list/list.go b/pkg/list/list.go new file mode 100644 index 0000000..a0ac924 --- /dev/null +++ b/pkg/list/list.go @@ -0,0 +1,42 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package list + +import ( + "sort" + + "github.com/norbjd/pasuman/pkg/config" + "github.com/norbjd/pasuman/pkg/data" +) + +func List(profile string) ([]data.Entry, error) { + var d data.Data + + conf := config.GetConfig() + dataFile := config.GetDataFile(conf, profile) + + if err := d.FromFile(dataFile); err != nil { + return nil, err + } + + sort.Slice(d.Entries, func(i, j int) bool { + return d.Entries[i].UniqueID < d.Entries[j].UniqueID + }) + + return d.Entries, nil +} diff --git a/pkg/list/list_test.go b/pkg/list/list_test.go new file mode 100644 index 0000000..3543444 --- /dev/null +++ b/pkg/list/list_test.go @@ -0,0 +1,99 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package list + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/add" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/encrypt" + "github.com/stretchr/testify/require" +) + +func TestList(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + _, err := add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id1", + Description: "A desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + }) + require.NoError(t, err) + + _, err = add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id2", + Description: "Another desc", + Tags: []string{"tag1", "tag3", "tag4"}, + Site: "https://anothersite.pasuman", + ID: "otherId", + Password: "t0ps3cr3t!", + }) + require.NoError(t, err) + + got, err := List(constants.RootCmdDefaultProfile) + require.NoError(t, err) + + gotUniqueID1 := got[0] + require.Equal(t, "id1", gotUniqueID1.UniqueID) + require.Equal(t, "A desc", gotUniqueID1.Description) + require.ElementsMatch(t, []string{"tag1", "tag2"}, gotUniqueID1.Tags) + require.Equal(t, "https://mysupersite.pasuman", gotUniqueID1.Site) + + require.NotEqual(t, "myId", gotUniqueID1.ID) + gotUniqueID1ID, err := encrypt.Decrypt(pasumantest.TestMasterPassword, gotUniqueID1.ID) + require.NoError(t, err) + require.Equal(t, "myId", gotUniqueID1ID) + + require.NotEqual(t, "p4$$w0rd!", gotUniqueID1.Password) + gotUniqueID1Password, err := encrypt.Decrypt(pasumantest.TestMasterPassword, gotUniqueID1.Password) + require.NoError(t, err) + require.Equal(t, "p4$$w0rd!", gotUniqueID1Password) + + gotUniqueID2 := got[1] + require.Equal(t, "id2", gotUniqueID2.UniqueID) + require.Equal(t, "Another desc", gotUniqueID2.Description) + require.ElementsMatch(t, []string{"tag1", "tag3", "tag4"}, gotUniqueID2.Tags) + require.Equal(t, "https://anothersite.pasuman", gotUniqueID2.Site) + + require.NotEqual(t, "otherId", gotUniqueID2.ID) + gotUniqueID2ID, err := encrypt.Decrypt(pasumantest.TestMasterPassword, gotUniqueID2.ID) + require.NoError(t, err) + require.Equal(t, "otherId", gotUniqueID2ID) + + require.NotEqual(t, "t0ps3cr3t!", gotUniqueID2.Password) + gotUniqueID2Password, err := encrypt.Decrypt(pasumantest.TestMasterPassword, gotUniqueID2.Password) + require.NoError(t, err) + require.Equal(t, "t0ps3cr3t!", gotUniqueID2Password) +} + +func TestListEmpty(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + got, err := List(constants.RootCmdDefaultProfile) + require.NoError(t, err) + require.Len(t, got, 0) +} diff --git a/pkg/listprofiles/list_profiles.go b/pkg/listprofiles/list_profiles.go new file mode 100644 index 0000000..54cf83f --- /dev/null +++ b/pkg/listprofiles/list_profiles.go @@ -0,0 +1,57 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package listprofiles + +import ( + "encoding/json" + "io/ioutil" + "os" + "strings" + + "github.com/norbjd/pasuman/pkg/config" +) + +func ListProfiles() ([]string, error) { + configFileHandler, err := os.Open(config.PasumanConfigFile) + if err != nil { + return nil, err + } + + var config config.Config + + jsonDecoder := json.NewDecoder(configFileHandler) + if err = jsonDecoder.Decode(&config); err != nil { + return nil, err + } + + files, err := ioutil.ReadDir(config.DataDirectory) + if err != nil { + return nil, err + } + + var profiles []string + + for _, f := range files { + if strings.HasSuffix(f.Name(), ".json") { + split := strings.Split(f.Name(), ".") + profiles = append(profiles, strings.Join(split[:len(split)-1], ".")) + } + } + + return profiles, nil +} diff --git a/pkg/masterpassword/master_password.go b/pkg/masterpassword/master_password.go new file mode 100644 index 0000000..cf27935 --- /dev/null +++ b/pkg/masterpassword/master_password.go @@ -0,0 +1,134 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package masterpassword + +import ( + "fmt" + + "github.com/alexedwards/argon2id" + "github.com/norbjd/pasuman/pkg/config" + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/encrypt" + "github.com/norbjd/pasuman/pkg/util" +) + +// nolint: gomnd +var argon2idParams = argon2id.Params{ + SaltLength: 64, + KeyLength: 32, + Memory: 256 * 1024, + Iterations: 16, + Parallelism: 4, +} + +func IsSet() (bool, error) { + var d data.Data + + if err := d.FromFile(config.PasumanDataFile); err != nil { + return false, err + } + + if d.MasterPassword == "" { + return false, nil + } + + return true, nil +} + +func IsCorrect(masterPassword string) (bool, error) { + var data data.Data + + if err := data.FromFile(config.PasumanDataFile); err != nil { + return false, err + } + + match, _, err := argon2id.CheckHash(masterPassword, data.MasterPassword) + if err != nil { + return false, err + } + + return match, nil +} + +// SetMasterPassword - change master password and re-encrypt all encrypted values +// with the new master password. +// nolint: funlen +func SetMasterPassword(oldMasterPassword, newMasterPassword string) error { + if newMasterPassword == "" { + return util.ErrMasterPasswordMustNotBeEmpty + } + + var err error + + if oldMasterPassword != "" { + oldMasterPasswordCorrect, err := IsCorrect(oldMasterPassword) + if !oldMasterPasswordCorrect { + return util.ErrMasterPasswordIncorrect + } + + if err != nil { + return err + } + } + + var data data.Data + + if err := data.FromFile(config.PasumanDataFile); err != nil { + return err + } + + if data.MasterPassword, err = argon2id.CreateHash(newMasterPassword, &argon2idParams); err != nil { + return err + } + + if oldMasterPassword != "" { + fmt.Println("Re-encrypting all entries with new master password, please wait...") + + for idx := range data.Entries { + encryptedEntryID := data.Entries[idx].ID + + entryID, err := encrypt.Decrypt(oldMasterPassword, encryptedEntryID) + if err != nil { + return err + } + + if data.Entries[idx].ID, err = encrypt.Encrypt(newMasterPassword, entryID); err != nil { + return err + } + + encryptedEntryPassword := data.Entries[idx].Password + + entryPassword, err := encrypt.Decrypt(oldMasterPassword, encryptedEntryPassword) + if err != nil { + return err + } + + if data.Entries[idx].Password, err = encrypt.Encrypt(newMasterPassword, entryPassword); err != nil { + return err + } + } + + fmt.Println("Done!") + } + + if err = data.ToFile(config.PasumanDataFile); err != nil { + return err + } + + return nil +} diff --git a/pkg/masterpassword/master_password_test.go b/pkg/masterpassword/master_password_test.go new file mode 100644 index 0000000..a0fee40 --- /dev/null +++ b/pkg/masterpassword/master_password_test.go @@ -0,0 +1,164 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package masterpassword + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/add" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/get" + "github.com/norbjd/pasuman/pkg/util" + "github.com/stretchr/testify/require" +) + +func TestIsSet(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + isSet, err := IsSet() + require.NoError(t, err) + require.True(t, isSet) +} + +func TestIsCorrect(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + isCorrect, err := IsCorrect(pasumantest.TestMasterPassword) + require.NoError(t, err) + require.True(t, isCorrect) + + isCorrect, err = IsCorrect("") + require.NoError(t, err) + require.False(t, isCorrect) + + isCorrect, err = IsCorrect(pasumantest.TestMasterPassword + "a") + require.NoError(t, err) + require.False(t, isCorrect) +} + +func TestSetMasterPassword(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + _, err := add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id1", + Description: "A desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + }) + require.NoError(t, err) + + _, err = add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id2", + Description: "Another desc", + Tags: []string{"tag1", "tag3", "tag4"}, + Site: "https://anothersite.pasuman", + ID: "otherId", + Password: "t0ps3cr3t!", + }) + require.NoError(t, err) + + type args struct { + oldMasterPassword string + newMasterPassword string + } + + tests := []struct { + args args + currentMasterPassword string + wantErr error + }{ + { + args: args{ + oldMasterPassword: "", + newMasterPassword: pasumantest.TestMasterPassword, + }, + currentMasterPassword: "", + wantErr: nil, + }, + { + args: args{ + oldMasterPassword: pasumantest.TestMasterPassword, + newMasterPassword: "", + }, + currentMasterPassword: pasumantest.TestMasterPassword, + wantErr: util.ErrMasterPasswordMustNotBeEmpty, + }, + { + args: args{ + oldMasterPassword: pasumantest.TestMasterPassword, + newMasterPassword: "newMasterPass", + }, + currentMasterPassword: pasumantest.TestMasterPassword, + wantErr: nil, + }, + { + args: args{ + oldMasterPassword: "newMasterPass", + newMasterPassword: pasumantest.TestMasterPassword, + }, + currentMasterPassword: "newMasterPass", + wantErr: nil, + }, + } + + for _, tt := range tests { + err := SetMasterPassword(tt.args.oldMasterPassword, tt.args.newMasterPassword) + + require.ErrorIs(t, tt.wantErr, err) + + if tt.wantErr == nil { + _, err := get.Sensitive(tt.args.oldMasterPassword, "id1") + require.Error(t, err) + + gotUniqueID1, err := get.Sensitive(tt.args.newMasterPassword, "id1") + require.NoError(t, err) + + require.Equal(t, "myId", gotUniqueID1.ID) + require.Equal(t, "p4$$w0rd!", gotUniqueID1.Password) + + _, err = get.Sensitive(tt.args.oldMasterPassword, "id2") + require.Error(t, err) + + gotUniqueID2, err := get.Sensitive(tt.args.newMasterPassword, "id2") + require.NoError(t, err) + + require.Equal(t, "otherId", gotUniqueID2.ID) + require.Equal(t, "t0ps3cr3t!", gotUniqueID2.Password) + } else { + gotUniqueID1, err := get.Sensitive(tt.currentMasterPassword, "id1") + require.NoError(t, err) + + require.Equal(t, "myId", gotUniqueID1.ID) + require.Equal(t, "p4$$w0rd!", gotUniqueID1.Password) + + gotUniqueID2, err := get.Sensitive(tt.currentMasterPassword, "id2") + require.NoError(t, err) + + require.Equal(t, "otherId", gotUniqueID2.ID) + require.Equal(t, "t0ps3cr3t!", gotUniqueID2.Password) + } + } +} diff --git a/pkg/remove/remove.go b/pkg/remove/remove.go new file mode 100644 index 0000000..60649b2 --- /dev/null +++ b/pkg/remove/remove.go @@ -0,0 +1,53 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package remove + +import ( + "errors" + + "github.com/norbjd/pasuman/pkg/config" + "github.com/norbjd/pasuman/pkg/data" +) + +var ErrNotFound = errors.New("entry not found") + +func Remove(uniqueID string) error { + var d data.Data + + if err := d.FromFile(config.PasumanDataFile); err != nil { + return err + } + + index := -1 + + for idx := range d.Entries { + if d.Entries[idx].UniqueID == uniqueID { + index = idx + + break + } + } + + if index == -1 { + return ErrNotFound + } + + d.Entries = append(d.Entries[:index], d.Entries[index+1:]...) + + return d.ToFile(config.PasumanDataFile) +} diff --git a/pkg/remove/remove_test.go b/pkg/remove/remove_test.go new file mode 100644 index 0000000..a64fe7b --- /dev/null +++ b/pkg/remove/remove_test.go @@ -0,0 +1,86 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package remove + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/add" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/data" + "github.com/stretchr/testify/require" +) + +func TestRemove(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + _, err := add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id1", + Description: "A desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + }) + require.NoError(t, err) + + _, err = add.Add(pasumantest.TestMasterPassword, data.Entry{ + UniqueID: "id2", + Description: "Another desc", + Tags: []string{"tag1", "tag3", "tag4"}, + Site: "https://anothersite.pasuman", + ID: "otherId", + Password: "t0ps3cr3t!", + }) + require.NoError(t, err) + + tests := []struct { + uniqueID string + wantErr error + }{ + { + uniqueID: "id3", + wantErr: ErrNotFound, + }, + { + uniqueID: "id1", + wantErr: nil, + }, + { + uniqueID: "id1", + wantErr: ErrNotFound, + }, + { + uniqueID: "id2", + wantErr: nil, + }, + { + uniqueID: "id2", + wantErr: ErrNotFound, + }, + } + + for _, tt := range tests { + err := Remove(tt.uniqueID) + + require.ErrorIs(t, tt.wantErr, err) + } +} diff --git a/pkg/search/search.go b/pkg/search/search.go new file mode 100644 index 0000000..90c42bf --- /dev/null +++ b/pkg/search/search.go @@ -0,0 +1,88 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package search + +import ( + "strings" + + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/list" +) + +func Search(profile string, term string, caseSensitive bool) ([]data.Entry, error) { + entries, err := list.List(profile) + if err != nil { + return nil, err + } + + if !caseSensitive { + term = strings.ToLower(term) + } + + searchResults := make([]data.Entry, 0) + + for _, entry := range entries { + uniqueID := entry.UniqueID + if !caseSensitive { + uniqueID = strings.ToLower(uniqueID) + } + + if strings.Contains(uniqueID, term) { + searchResults = append(searchResults, entry) + + continue + } + + description := entry.Description + if !caseSensitive { + description = strings.ToLower(description) + } + + if strings.Contains(description, term) { + searchResults = append(searchResults, entry) + + continue + } + + tags := entry.Tags + if !caseSensitive { + for idx := range tags { + tags[idx] = strings.ToLower(tags[idx]) + } + } + + for _, tag := range tags { + if strings.Contains(tag, term) { + searchResults = append(searchResults, entry) + } + } + + site := entry.Site + if !caseSensitive { + site = strings.ToLower(site) + } + + if strings.Contains(site, term) { + searchResults = append(searchResults, entry) + + continue + } + } + + return searchResults, nil +} diff --git a/pkg/search/search_test.go b/pkg/search/search_test.go new file mode 100644 index 0000000..0ea56f9 --- /dev/null +++ b/pkg/search/search_test.go @@ -0,0 +1,143 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package search + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/add" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/data" + "github.com/stretchr/testify/require" +) + +func TestSearch(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + entry1 := data.Entry{ + UniqueID: "id1", + Description: "A desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + } + _, err := add.Add(pasumantest.TestMasterPassword, entry1) + require.NoError(t, err) + + entry2 := data.Entry{ + UniqueID: "id2", + Description: "Another desc", + Tags: []string{"tag1", "tag3", "tag4"}, + Site: "https://anothersite.pasuman", + ID: "otherId", + Password: "t0ps3cr3t!", + } + _, err = add.Add(pasumantest.TestMasterPassword, entry2) + require.NoError(t, err) + + type args struct { + term string + caseSensitive bool + } + + tests := []struct { + args args + want []data.Entry + wantErr error + }{ + { + args: args{ + term: "does-not-exist", + caseSensitive: false, + }, + want: nil, + wantErr: nil, + }, + { + args: args{ + term: "id", + caseSensitive: false, + }, + want: []data.Entry{entry1, entry2}, + wantErr: nil, + }, + { + args: args{ + term: "desc", + caseSensitive: false, + }, + want: []data.Entry{entry1, entry2}, + wantErr: nil, + }, + { + args: args{ + term: "A desc", + caseSensitive: false, + }, + want: []data.Entry{entry1}, + wantErr: nil, + }, + { + args: args{ + term: "A DEsc", + caseSensitive: true, + }, + want: nil, + wantErr: nil, + }, + { + args: args{ + term: "tag2", + caseSensitive: false, + }, + want: []data.Entry{entry1}, + wantErr: nil, + }, + { + args: args{ + term: "myId", + caseSensitive: false, + }, + want: nil, + wantErr: nil, + }, + } + + for _, tt := range tests { + entries, err := Search(constants.RootCmdDefaultProfile, tt.args.term, tt.args.caseSensitive) + + require.ErrorIs(t, tt.wantErr, err) + + // do not compare ID and Password + for i := range entries { + entries[i].ID = "" + entries[i].Password = "" + } + + for i := range tt.want { + tt.want[i].ID = "" + tt.want[i].Password = "" + } + + require.ElementsMatch(t, tt.want, entries) + } +} diff --git a/pkg/update/update.go b/pkg/update/update.go new file mode 100644 index 0000000..ab574f7 --- /dev/null +++ b/pkg/update/update.go @@ -0,0 +1,82 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package update + +import ( + "errors" + + "github.com/norbjd/pasuman/pkg/config" + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/encrypt" +) + +var ErrNotFound = errors.New("entry not found") + +func Update(masterPassword string, uniqueID string, e data.Entry) error { + var d data.Data + + if err := d.FromFile(config.PasumanDataFile); err != nil { + return err + } + + index := -1 + + for idx := range d.Entries { + if d.Entries[idx].UniqueID == uniqueID { + index = idx + + break + } + } + + if index == -1 { + return ErrNotFound + } + + if e.UniqueID != "" { + d.Entries[index].UniqueID = e.UniqueID + } + + if e.Description != "" { + d.Entries[index].Description = e.Description + } + + if len(e.Tags) > 0 { + d.Entries[index].Tags = e.Tags + } + + if e.Site != "" { + d.Entries[index].Site = e.Site + } + + var err error + + if e.ID != "" { + if d.Entries[index].ID, err = encrypt.Encrypt(masterPassword, e.ID); err != nil { + return err + } + } + + if e.Password != "" { + if d.Entries[index].Password, err = encrypt.Encrypt(masterPassword, e.Password); err != nil { + return err + } + } + + return d.ToFile(config.PasumanDataFile) +} diff --git a/pkg/update/update_test.go b/pkg/update/update_test.go new file mode 100644 index 0000000..616a0f3 --- /dev/null +++ b/pkg/update/update_test.go @@ -0,0 +1,81 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package update + +import ( + "os" + "testing" + + "github.com/norbjd/pasuman/internal/pkg/pasumantest" + "github.com/norbjd/pasuman/pkg/add" + "github.com/norbjd/pasuman/pkg/constants" + "github.com/norbjd/pasuman/pkg/data" + "github.com/norbjd/pasuman/pkg/get" + "github.com/stretchr/testify/require" +) + +func TestUpdate(t *testing.T) { + tempDir := pasumantest.Init(t, constants.RootCmdDefaultProfile) + defer os.RemoveAll(tempDir) + + entry := data.Entry{ + UniqueID: "id1", + Description: "A desc", + Tags: []string{"tag1", "tag2"}, + Site: "https://mysupersite.pasuman", + ID: "myId", + Password: "p4$$w0rd!", + } + _, err := add.Add(pasumantest.TestMasterPassword, entry) + require.NoError(t, err) + + tests := []struct { + uniqueID string + entry data.Entry + wantErr error + }{ + { + uniqueID: "id1", + entry: data.Entry{ + UniqueID: "newId1", + Description: "New desc", + Tags: []string{"tag2", "tag3"}, + Site: "https://newsite.pasuman", + ID: "newId", + Password: "n€wp4$$w0rd!", + }, + }, + { + uniqueID: "id3", + wantErr: ErrNotFound, + }, + } + + for _, tt := range tests { + err := Update(pasumantest.TestMasterPassword, tt.uniqueID, tt.entry) + + require.ErrorIs(t, err, tt.wantErr) + + if tt.wantErr == nil { + updated, err := get.Sensitive(pasumantest.TestMasterPassword, tt.entry.UniqueID) + require.NoError(t, err) + + require.Equal(t, tt.entry, updated) + } + } +} diff --git a/pkg/util/errors.go b/pkg/util/errors.go new file mode 100644 index 0000000..c6b9824 --- /dev/null +++ b/pkg/util/errors.go @@ -0,0 +1,25 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package util + +import "errors" + +var ( + ErrMasterPasswordMustNotBeEmpty = errors.New("master password must not be empty") + ErrMasterPasswordIncorrect = errors.New("master password incorrect") +) diff --git a/pkg/util/io.go b/pkg/util/io.go new file mode 100644 index 0000000..1d2bd76 --- /dev/null +++ b/pkg/util/io.go @@ -0,0 +1,120 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package util + +import ( + "bufio" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "os/signal" + "syscall" + + "golang.org/x/term" +) + +var errInvalidTerminal = errors.New("stdin and stdout should be terminal") + +// ReadLine - read from stdin and handles backspace. +// Inspired from https://gist.github.com/artyom/a59e2707976124f387f5 +func ReadLine() (string, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { + return "", errInvalidTerminal + } + + oldState, err := term.MakeRaw(0) + if err != nil { + return "", err + } + + defer func() { + _ = term.Restore(0, oldState) + }() + + screen := struct { + io.Reader + io.Writer + }{os.Stdin, os.Stdout} + termScreen := term.NewTerminal(screen, "") + + line, err := termScreen.ReadLine() + + if errors.Is(err, io.EOF) { + return "", nil + } + + if err != nil { + return "", err + } + + return line, nil +} + +// ReadPassword - read password from stdin and handle CTRL+C to restore. +// Inspired from https://groups.google.com/g/golang-nuts/c/DCl8xUJMJJ0. +func ReadPassword() (string, error) { + // nolint: unconvert + // int conversion is necessary to build for windows + fd := int(syscall.Stdin) + + if !term.IsTerminal(fd) { + // if not a terminal, use environment variable + // warning: it should not be used outside of tests + if password := os.Getenv("PASUMAN_MASTER_PASSWORD"); password != "" { + return password, nil + } + } + + oldState, err := term.GetState(fd) + if err != nil { + return "", err + } + + defer func() { + _ = term.Restore(fd, oldState) + }() + + sigch := make(chan os.Signal, 1) + signal.Notify(sigch, os.Interrupt) + + go func() { + for range sigch { + _ = term.Restore(fd, oldState) + + fmt.Println() + os.Exit(1) + } + }() + + password, err := term.ReadPassword(fd) + if err != nil { + return "", err + } + + return string(password), nil +} + +// CopyToClipboard - uses ANSI OSC 52 to copy text to clipboard, +// it may not work on some terminals (see https://github.com/ojroques/vim-oscyank/blob/main/README.md). +func CopyToClipboard(s string) { + out := bufio.NewWriter(os.Stdout) + fmt.Fprintf(out, "\033]52;c;%s\a", base64.StdEncoding.EncodeToString([]byte(s))) + out.Flush() +} diff --git a/pkg/util/io_test.go b/pkg/util/io_test.go new file mode 100644 index 0000000..9f4e171 --- /dev/null +++ b/pkg/util/io_test.go @@ -0,0 +1,53 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package util + +import ( + "encoding/base64" + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCopyToClipboard(t *testing.T) { + t.Parallel() + + toCopy := "this should go to clipboard" + + realStdout := os.Stdout + defer func() { os.Stdout = realStdout }() + + r, fakeStdout, err := os.Pipe() + require.NoError(t, err) + + os.Stdout = fakeStdout + + CopyToClipboard(toCopy) + + err = fakeStdout.Close() + require.NoError(t, err) + + fakeStdoutContent, err := ioutil.ReadAll(r) + require.NoError(t, err) + + toCopyBase64 := base64.StdEncoding.EncodeToString([]byte(toCopy)) + + require.Equal(t, "\033]52;c;"+toCopyBase64+"\a", string(fakeStdoutContent)) +} diff --git a/pkg/util/table.go b/pkg/util/table.go new file mode 100644 index 0000000..3ab002c --- /dev/null +++ b/pkg/util/table.go @@ -0,0 +1,56 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package util + +import ( + "fmt" + "io" + "text/tabwriter" +) + +const tabWidth = 8 + +func RenderTable(w io.Writer, headerColumns []string, lines [][]string) { + tabW := tabwriter.NewWriter(w, 0, tabWidth, 0, '\t', 0) + + for _, col := range headerColumns { + fmt.Fprintf(tabW, "%s\t", col) + } + + fmt.Fprintln(tabW) + + for _, col := range headerColumns { + for range col { + fmt.Fprintf(tabW, "%s", "-") + } + + fmt.Fprintf(tabW, "\t") + } + + fmt.Fprintln(tabW) + + for _, line := range lines { + for _, col := range line { + fmt.Fprintf(tabW, "%s\t", col) + } + + fmt.Fprintln(tabW) + } + + tabW.Flush() +} diff --git a/pkg/util/table_test.go b/pkg/util/table_test.go new file mode 100644 index 0000000..1724820 --- /dev/null +++ b/pkg/util/table_test.go @@ -0,0 +1,48 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package util + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRenderTable(t *testing.T) { + t.Parallel() + + headerColumns := []string{"col1", "col2", "col333333333333333333"} + + lines := [][]string{ + {"l1", "l2", "l3"}, + {"loooooooooooooongline", "l4", "l5"}, + } + + var b bytes.Buffer + + RenderTable(&b, headerColumns, lines) + + table := "" + + "col1\t\t\tcol2\tcol333333333333333333\t\n" + + "----\t\t\t----\t---------------------\t\n" + + "l1\t\t\tl2\tl3\t\t\t\n" + + "loooooooooooooongline\tl4\tl5\t\t\t\n" + + require.Equal(t, table, b.String()) +} diff --git a/pkg/util/uuid.go b/pkg/util/uuid.go new file mode 100644 index 0000000..8b5405b --- /dev/null +++ b/pkg/util/uuid.go @@ -0,0 +1,36 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package util + +import ( + "crypto/rand" + "fmt" +) + +// nolint: gomnd +func NewUUIDV4() (string, error) { + uuid := make([]byte, 16) + if _, err := rand.Read(uuid); err != nil { + return "", err + } + + uuid[6] = (uuid[6] & 0x0f) | 0x40 + uuid[8] = (uuid[8] & 0x3f) | 0x80 + + return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]), nil +} diff --git a/pkg/util/uuid_test.go b/pkg/util/uuid_test.go new file mode 100644 index 0000000..1b112f6 --- /dev/null +++ b/pkg/util/uuid_test.go @@ -0,0 +1,47 @@ +// This file is part of pasuman (https://github.com/norbjd/pasuman). +// +// pasuman is a command-line password manager. +// Copyright (C) 2022 norbjd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package util + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestNewUUIDV4(t *testing.T) { + t.Parallel() + + uuids := make(map[string]struct{}, 1000000) + + for i := 1; i <= 1000000; i++ { + got, err := NewUUIDV4() + require.NoError(t, err) + + if _, alreadyExists := uuids[got]; alreadyExists { + t.Logf("Collision found: %s\n", got) + t.FailNow() + } + + uuids[got] = struct{}{} + + uuidParsed, err := uuid.Parse(got) + require.NoError(t, err) + require.Equal(t, uuid.Version(4), uuidParsed.Version()) + } +} diff --git a/skywalking-eyes-config.yaml b/skywalking-eyes-config.yaml new file mode 100644 index 0000000..27896d8 --- /dev/null +++ b/skywalking-eyes-config.yaml @@ -0,0 +1,30 @@ +header: + license: + spdx-id: GPL-3.0-only + copyright-owner: norbjd + content: | + This file is part of pasuman (https://github.com/norbjd/pasuman). + + pasuman is a command-line password manager. + Copyright (C) 2022 norbjd + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, version 3 of the License. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + paths: + - '**/*.go' +dependency: + files: + - go.mod + licenses: + - name: gopkg.in/check.v1 + version: v1.0.0-20201130134442-10cb98267c6c + license: BSD-2-Clause # see https://github.com/go-check/check/blob/10cb98267c6c/LICENSE