From f6017f751c4bf5344879631434fabeb434fd2f62 Mon Sep 17 00:00:00 2001 From: Tobias Manske Date: Sat, 25 Mar 2023 22:26:34 +0100 Subject: [PATCH] Initial --- .flake8 | 4 + .gitignore | 7 + CONTRIBUTING.md | 66 + LICENSE | 674 +++++++++ Makefile | 72 + README.md | 170 +++ ...FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E.asc | 6 + ...y___arch-repo@tobiasmanske.de_de7641a7.asc | 6 + ...FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E.asc | 8 + keyringctl | 8 + libkeyringctl/__init__.py | 0 libkeyringctl/ci.py | 45 + libkeyringctl/cli.py | 229 +++ libkeyringctl/git.py | 55 + libkeyringctl/keyring.py | 1262 +++++++++++++++++ libkeyringctl/sequoia.py | 363 +++++ libkeyringctl/trust.py | 273 ++++ libkeyringctl/types.py | 38 + libkeyringctl/util.py | 341 +++++ libkeyringctl/verify.py | 342 +++++ pyproject.toml | 58 + tests/__init__.py | 0 tests/conftest.py | 344 +++++ tests/test_git.py | 45 + tests/test_keyring.py | 804 +++++++++++ tests/test_sequoia.py | 368 +++++ tests/test_trust.py | 381 +++++ tests/test_util.py | 198 +++ 28 files changed, 6167 insertions(+) create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 keyring/main/tobi/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E.asc create mode 100644 keyring/main/tobi/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E/uid/Tobias_Manske__Package_Signing_Key___arch-repo@tobiasmanske.de_de7641a7/Tobias_Manske__Package_Signing_Key___arch-repo@tobiasmanske.de_de7641a7.asc create mode 100644 keyring/main/tobi/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E/uid/Tobias_Manske__Package_Signing_Key___arch-repo@tobiasmanske.de_de7641a7/certification/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E.asc create mode 100755 keyringctl create mode 100644 libkeyringctl/__init__.py create mode 100644 libkeyringctl/ci.py create mode 100644 libkeyringctl/cli.py create mode 100644 libkeyringctl/git.py create mode 100644 libkeyringctl/keyring.py create mode 100644 libkeyringctl/sequoia.py create mode 100644 libkeyringctl/trust.py create mode 100644 libkeyringctl/types.py create mode 100644 libkeyringctl/util.py create mode 100644 libkeyringctl/verify.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_git.py create mode 100644 tests/test_keyring.py create mode 100644 tests/test_sequoia.py create mode 100644 tests/test_trust.py create mode 100644 tests/test_util.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..5811c8b --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 120 +output-file = flake8.txt +max-complexity = 10 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eac5b18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/build +*~ +archlinux-keyring-*.tar.gz +archlinux-keyring-*.tar.gz.sig +/.idea +.coverage +__pycache__/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..44d5587 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing + +These are the contribution guidelines for archlinux-keyring. +All code contributions fall under the terms of the GPL-3.0-or-later (see +[LICENSE](LICENSE)). + +Please read our distribution-wide [Code of +Conduct](https://terms.archlinux.org/docs/code-of-conduct/) before +contributing, to understand what actions will and will not be tolerated. + +Development of archlinux-keyring takes place on Arch Linux' Gitlab: +https://gitlab.archlinux.org/archlinux/archlinux-keyring. + +Any merge request to the repository requires two approvals of authorized +approvers (the current main key holders). + +## Discussion + +Discussion around archlinux-keyring may take place on the [arch-projects +mailing list](https://lists.archlinux.org/listinfo/arch-projects) and in +[#archlinux-projects](ircs://irc.libera.chat/archlinux-projects) on [Libera +Chat](https://libera.chat/). + +## Requirements + +The following additional packages need to be installed to be able to lint +and develop this project: + +* python-black +* python-coverage +* python-isort +* python-pytest +* python-tomli +* flake8 +* mypy + +## Keyringctl + +The `keyringctl` script is written in typed python, which makes use of +[sequoia](https://sequoia-pgp.org/)'s `sq` command. + +The script is type checked, linted and formatted using standard tooling. +When providing a merge request make sure to run `make lint`. + +## Testing + +Test cases are developed per module in the [test](test) directory and should +consist of atomic single expectation tests. A Huge test case asserting various +different expectations are discouraged and should be split into finer grained +test cases. + +To execute all tests using pytest +```bash +make test +``` + +To run keyring integrity and consistency checks +```bash +make check +``` + +## Web Key Directory + +Only tagged releases are built and exposed via WKD. This helps to ensure, that +inconsistent state of the keyring is not exposed to the enduser, which may make +use of it instantaneously. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -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..6ea53db --- /dev/null +++ b/Makefile @@ -0,0 +1,72 @@ +SHELL = /bin/bash +PREFIX ?= /usr/local +BUILD_DIR ?= build +KEYRING_TARGET_DIR ?= $(PREFIX)/share/pacman/keyrings/ +SCRIPT_TARGET_DIR ?= $(PREFIX)/bin +SYSTEMD_SYSTEM_UNIT_DIR ?= $(shell pkgconf --variable systemd_system_unit_dir systemd) +WKD_FQDN ?= archlinux.org +WKD_BUILD_DIR ?= $(BUILD_DIR)/wkd +KEYRING_FILE=archlinux.gpg +KEYRING_REVOKED_FILE=archlinux-revoked +KEYRING_TRUSTED_FILE=archlinux-trusted +WKD_SYNC_SCRIPT=archlinux-keyring-wkd-sync +WKD_SYNC_SERVICE_IN=archlinux-keyring-wkd-sync.service.in +WKD_SYNC_SERVICE=archlinux-keyring-wkd-sync.service +WKD_SYNC_TIMER=archlinux-keyring-wkd-sync.timer +SYSTEMD_TIMER_DIR=$(SYSTEMD_SYSTEM_UNIT_DIR)/timers.target.wants/ +SOURCES := $(shell find keyring) $(shell find libkeyringctl -name '*.py' -or -type d) keyringctl + +all: build + +lint: + black --check --diff keyringctl libkeyringctl tests + isort --diff . + flake8 keyringctl libkeyringctl tests + mypy --install-types --non-interactive keyringctl libkeyringctl tests + +fmt: + black . + isort . + +check: + ./keyringctl -v check + +test: + coverage run + coverage xml + coverage report --fail-under=100.0 + +build: $(SOURCES) + ./keyringctl -v $(BUILD_DIR) + +wkd: build + sq -f wkd generate -s $(WKD_BUILD_DIR)/ $(WKD_FQDN) $(BUILD_DIR)/$(KEYRING_FILE) + +wkd_inspect: wkd + for file in $(WKD_BUILD_DIR)/.well-known/openpgpkey/$(WKD_FQDN)/hu/*; do sq inspect $$file; done + +wkd_sync_service: wkd_sync/$(WKD_SYNC_SERVICE_IN) + sed -e 's|SCRIPT_TARGET_DIR|$(SCRIPT_TARGET_DIR)|' wkd_sync/$(WKD_SYNC_SERVICE_IN) > $(BUILD_DIR)/$(WKD_SYNC_SERVICE) + +clean: + rm -rf $(BUILD_DIR) $(WKD_BUILD_DIR) + +install: build wkd_sync_service + install -vDm 644 build/{$(KEYRING_FILE),$(KEYRING_REVOKED_FILE),$(KEYRING_TRUSTED_FILE)} -t $(DESTDIR)$(KEYRING_TARGET_DIR) + install -vDm 755 wkd_sync/$(WKD_SYNC_SCRIPT) -t $(DESTDIR)$(SCRIPT_TARGET_DIR) + install -vDm 644 build/$(WKD_SYNC_SERVICE) -t $(DESTDIR)$(SYSTEMD_SYSTEM_UNIT_DIR) + install -vDm 644 wkd_sync/$(WKD_SYNC_TIMER) -t $(DESTDIR)$(SYSTEMD_SYSTEM_UNIT_DIR) + install -vdm 755 $(DESTDIR)$(SYSTEMD_TIMER_DIR) + ln -fsv ../$(WKD_SYNC_TIMER) $(DESTDIR)$(SYSTEMD_TIMER_DIR)/$(WKD_SYNC_TIMER) + +uninstall: + rm -fv $(DESTDIR)$(KEYRING_TARGET_DIR)/{$(KEYRING_FILE),$(KEYRING_REVOKED_FILE),$(KEYRING_TRUSTED_FILE)} + rmdir -pv --ignore-fail-on-non-empty $(DESTDIR)$(KEYRING_TARGET_DIR) + rm -v $(DESTDIR)$(SCRIPT_TARGET_DIR)/$(WKD_SYNC_SCRIPT) + rmdir -pv --ignore-fail-on-non-empty $(DESTDIR)$(SCRIPT_TARGET_DIR) + rm -v $(DESTDIR)$(SYSTEMD_SYSTEM_UNIT_DIR)/{$(WKD_SYNC_SERVICE),$(WKD_SYNC_TIMER)} + rmdir -pv --ignore-fail-on-non-empty $(DESTDIR)$(SYSTEMD_SYSTEM_UNIT_DIR) + rm -v $(DESTDIR)$(SYSTEMD_TIMER_DIR)/$(WKD_SYNC_TIMER) + rmdir -pv --ignore-fail-on-non-empty $(DESTDIR)$(SYSTEMD_TIMER_DIR) + +.PHONY: all lint fmt check test clean install uninstall wkd wkd_inspect diff --git a/README.md b/README.md new file mode 100644 index 0000000..2537028 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# archlinux-keyring + +The archlinux-keyring project holds PGP packet material and tooling +(`keyringctl`) to create the distribution keyring for Arch Linux. +The keyring is used by pacman to establish the web of trust for the packagers +of the distribution. + +The PGP packets describing the main signing keys can be found below the +[keyring/main](keyring/main) directory, while those of the packagers are located below the +[keyring/packager](keyring/packager) directory. + +## Requirements + +The following packages need to be installed to be able to create a PGP keyring +from the provided data structure and to install it: + +Build: + +* make +* findutils +* pkgconf +* systemd + +Runtime: + +* python +* sequoia-sq + +Optional: + +* hopenpgp-tools (verify) +* sq-keyring-linter (verify) +* git (ci) + +## Usage + +### Build + +Build all PGP artifacts (keyring, ownertrust, revoked files) to the build directory +```bash +./keyringctl build +``` + +### Import + +Import a new packager key by deriving the username from the filename. +```bash +./keyringctl import .asc +``` + +Alternatively import a file or directory and override the username +```bash +./keyringctl import --name +``` + +Updates to existing keys will automatically derive the username from the known fingerprint. +```bash +./keyringctl import +``` + +Main key imports support the same options plus a mandatory `--main` +```bash +./keyringctl import --main .asc +``` + +### Export + +Export the whole keyring including main and packager to stdout +```bash +./keyringctl export +``` + +Limit to specific certs using an output file +```bash +./keyringctl export --output +``` + +### List + +List all certificates in the keyring +```bash +./keyringctl list +``` + +Only show a specific main key +```bash +./keyringctl list --main +``` + +### Inspect + +Inspect all certificates in the keyring +```bash +./keyringctl inspect +``` + +Only inspect a specific main key +```bash +./keyringctl inspect --main +``` + +### Verify + +Verify certificates against modern expectations and assumptions +```bash +./keyringctl verify +``` + +## Installation + +To install archlinux-keyring system-wide use the included `Makefile`: + +```bash +make install +``` + +## Contribute + +Read our [contributing guide](CONTRIBUTING.md) to learn more about guidelines and +how to provide fixes or improvements for the code base. + +## Releases + +[Releases of +archlinux-keyring](https://gitlab.archlinux.org/archlinux/archlinux-keyring/-/tags) +are exclusively created by [keyring maintainers](https://gitlab.archlinux.org/archlinux/archlinux-keyring/-/project_members?with_inherited_permissions=exclude). + +The tags are signed with one of the following legitimate keys: + +``` +Christian Hesse +02FD 1C7A 934E 6145 4584 9F19 A623 4074 498E 9CEE + +David Runge +C7E7 8494 66FE 2358 3435 8837 7258 734B 41C3 1549 + +Pierre Schmitz +4AA4 767B BC9C 4B1D 18AE 28B7 7F2D 434B 9741 E8AC + +Florian Pritz +CFA6 AF15 E5C7 4149 FC1D 8C08 6D16 55C1 4CE1 C13E + +Giancarlo Razzolini +ECCA C84C 1BA0 8A6C C8E6 3FBB F22F B1D7 8A77 AEAB + +Levente Polyak +E240 B57E 2C46 30BA 768E 2F26 FC1B 547C 8D81 72C8 + +Morten Linderud +C100 3466 7663 4E80 C940 FB9E 9C02 FF41 9FEC BE16 +``` + +To verify a tag, first import the relevant PGP keys: + +```bash +gpg --auto-key-locate wkd --search-keys +``` + +Afterwards a tag can be verified from a clone of this repository. Please note +that one **must** check the used key of the signature against the legitimate +keys listed above: + +```bash +git verify-tag +``` + +## License + +Archlinux-keyring is licensed under the terms of the **GPL-3.0-or-later** (see +[LICENSE](LICENSE)). diff --git a/keyring/main/tobi/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E.asc b/keyring/main/tobi/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E.asc new file mode 100644 index 0000000..ed364c9 --- /dev/null +++ b/keyring/main/tobi/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E.asc @@ -0,0 +1,6 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xjMEZB8uOxYJKwYBBAHaRw8BAQdAc29ugqSUSoDvIKuQSdXr6GiItT97VW/pCc3J +0rmyX48= +=MB6B +-----END PGP PUBLIC KEY BLOCK----- diff --git a/keyring/main/tobi/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E/uid/Tobias_Manske__Package_Signing_Key___arch-repo@tobiasmanske.de_de7641a7/Tobias_Manske__Package_Signing_Key___arch-repo@tobiasmanske.de_de7641a7.asc b/keyring/main/tobi/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E/uid/Tobias_Manske__Package_Signing_Key___arch-repo@tobiasmanske.de_de7641a7/Tobias_Manske__Package_Signing_Key___arch-repo@tobiasmanske.de_de7641a7.asc new file mode 100644 index 0000000..1a96c75 --- /dev/null +++ b/keyring/main/tobi/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E/uid/Tobias_Manske__Package_Signing_Key___arch-repo@tobiasmanske.de_de7641a7/Tobias_Manske__Package_Signing_Key___arch-repo@tobiasmanske.de_de7641a7.asc @@ -0,0 +1,6 @@ +-----BEGIN PGP ARMORED FILE----- + +zT9Ub2JpYXMgTWFuc2tlIChQYWNrYWdlIFNpZ25pbmcgS2V5KSA8YXJjaC1yZXBv +QHRvYmlhc21hbnNrZS5kZT4= +=eJuk +-----END PGP ARMORED FILE----- diff --git a/keyring/main/tobi/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E/uid/Tobias_Manske__Package_Signing_Key___arch-repo@tobiasmanske.de_de7641a7/certification/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E.asc b/keyring/main/tobi/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E/uid/Tobias_Manske__Package_Signing_Key___arch-repo@tobiasmanske.de_de7641a7/certification/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E.asc new file mode 100644 index 0000000..ad0d49b --- /dev/null +++ b/keyring/main/tobi/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E/uid/Tobias_Manske__Package_Signing_Key___arch-repo@tobiasmanske.de_de7641a7/certification/C3FE87CFB8F8D503AE03EC1C033E7F3DC71FE89E.asc @@ -0,0 +1,8 @@ +-----BEGIN PGP SIGNATURE----- + +wpwEExYKAEQCGwMFCQHhM4AFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AWIQTD +/ofPuPjVA64D7BwDPn89xx/ongUCZB8vfgIZAQAKCRADPn89xx/onrZlAP9hoyA6 +wyDPNWJXiP+VI0e1OW4YPSMGN/otWz36iBPVJAEA+A6Oe8ROrKdr7NLxoKE3EqHz +JIseK86lWj9UA56q2wo= +=3Aie +-----END PGP SIGNATURE----- diff --git a/keyringctl b/keyringctl new file mode 100755 index 0000000..01fb30e --- /dev/null +++ b/keyringctl @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from libkeyringctl.cli import main + +if __name__ == "__main__": + main() diff --git a/libkeyringctl/__init__.py b/libkeyringctl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libkeyringctl/ci.py b/libkeyringctl/ci.py new file mode 100644 index 0000000..31b0210 --- /dev/null +++ b/libkeyringctl/ci.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +from os import environ +from pathlib import Path +from typing import List + +from .git import git_changed_files +from .util import get_parent_cert_paths +from .verify import verify + + +def ci(working_dir: Path, keyring_root: Path, project_root: Path) -> None: + """Verify certificates against modern expectations using sq-keyring-linter and hokey + + Currently only newly added certificates will be checked against the expectations as existing + keys are not all fully compatible with those assumptions. + New certificates are determined by using $CI_MERGE_REQUEST_DIFF_BASE_SHA as the base, + + Parameters + ---------- + working_dir: A directory to use for temporary files + keyring_root: The keyring root directory to look up username shorthand sources + project_root: Path to the root of the git repository + """ + + ci_merge_request_diff_base = environ.get("CI_MERGE_REQUEST_DIFF_BASE_SHA") + created, deleted, modified = git_changed_files( + git_path=project_root, base=ci_merge_request_diff_base, paths=[Path("keyring")] + ) + + changed_certificates: List[Path] = list(get_parent_cert_paths(paths=created + deleted + modified)) + + verify( + working_dir=working_dir, + keyring_root=keyring_root, + sources=changed_certificates, + lint_hokey=False, + lint_sq_keyring=False, + ) + + added_certificates: List[Path] = [ + path for path in changed_certificates if (path / f"{path.name}.asc").relative_to(project_root) in created + ] + if added_certificates: + verify(working_dir=working_dir, keyring_root=keyring_root, sources=added_certificates) diff --git a/libkeyringctl/cli.py b/libkeyringctl/cli.py new file mode 100644 index 0000000..3334c17 --- /dev/null +++ b/libkeyringctl/cli.py @@ -0,0 +1,229 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +from argparse import ArgumentParser +from logging import DEBUG +from logging import basicConfig +from logging import debug +from pathlib import Path +from tempfile import TemporaryDirectory +from tempfile import mkdtemp + +from .ci import ci +from .keyring import Username +from .keyring import build +from .keyring import convert +from .keyring import export +from .keyring import inspect_keyring +from .keyring import list_keyring +from .types import TrustFilter +from .util import absolute_path +from .util import cwd +from .verify import verify + +parser = ArgumentParser() +parser.add_argument( + "-v", "--verbose", action="store_true", help="Causes to print debugging messages about the progress" +) +parser.add_argument("--wait", action="store_true", help="Block before cleaning up the temp directory") +parser.add_argument( + "-f", + "--force", + action="store_true", + default=False, + help="force the execution of subcommands (e.g. overwriting of files)", +) +subcommands = parser.add_subparsers(dest="subcommand") + +convert_parser = subcommands.add_parser( + "convert", + help="convert one or multiple PGP public keys to a decomposed directory structure", +) +convert_parser.add_argument("source", type=absolute_path, nargs="+", help="Files or directorie to convert") +convert_parser.add_argument("--target", type=absolute_path, help="Target directory instead of a random tmpdir") +convert_parser.add_argument( + "--name", + type=Username, + default=None, + help="override the username to use (only useful when using a single file as source)", +) + +import_parser = subcommands.add_parser( + "import", + help="import one or several PGP keys to the keyring directory structure", +) +import_parser.add_argument("source", type=absolute_path, nargs="+", help="Files or directories to import") +import_parser.add_argument( + "--name", + type=Username, + default=None, + help="override the username to use (only useful when using a single file as source)", +) +import_parser.add_argument("--main", action="store_true", help="Import a main signing key into the keyring") + +export_parser = subcommands.add_parser( + "export", + help="export a directory structure of PGP packet data to a combined file", +) +export_parser.add_argument("-o", "--output", type=absolute_path, help="file to write PGP packet data to") +export_parser.add_argument( + "source", + nargs="*", + help="username, fingerprint or directories containing certificates", + type=absolute_path, +) + +build_parser = subcommands.add_parser( + "build", + help="build keyring PGP artifacts alongside ownertrust and revoked status files", +) + +list_parser = subcommands.add_parser( + "list", + help="list the certificates in the keyring", +) +list_parser.add_argument("--main", action="store_true", help="List main signing keys instead of packager keys") +list_parser.add_argument( + "--trust", + choices=[e.value for e in TrustFilter], + default=TrustFilter.all.value, + help="Filter the list based on trust", +) +list_parser.add_argument( + "source", + nargs="*", + help="username, fingerprint or directories containing certificates", + type=absolute_path, +) + +inspect_parser = subcommands.add_parser( + "inspect", + help="inspect certificates in the keyring and pretty print the data", +) +inspect_parser.add_argument( + "source", + nargs="*", + help="username, fingerprint or directories containing certificates", + type=absolute_path, +) + +verify_parser = subcommands.add_parser( + "verify", + help="verify certificates against modern expectations", +) +verify_parser.add_argument( + "source", + nargs="*", + help="username, fingerprint or directories containing certificates", + type=absolute_path, +) +verify_parser.add_argument("--no-lint-hokey", dest="lint_hokey", action="store_false", help="Do not run hokey lint") +verify_parser.add_argument( + "--no-lint-sq-keyring", dest="lint_sq_keyring", action="store_false", help="Do not run sq-keyring-linter" +) +verify_parser.set_defaults(lint_hokey=True, lint_sq_keyring=True) + +check_parser = subcommands.add_parser( + "check", + help="Run keyring integrity and consistency checks", +) + +ci_parser = subcommands.add_parser( + "ci", + help="ci command to verify certain aspects and expectations in pipelines", +) + + +def main() -> None: # noqa: ignore=C901 + args = parser.parse_args() + + if args.verbose: + basicConfig(level=DEBUG) + + # temporary working directory that gets auto cleaned + with TemporaryDirectory(prefix="arch-keyringctl-") as tempdir: + project_root = Path(".").absolute() + keyring_root = Path("keyring").absolute() + working_dir = Path(tempdir) + debug(f"Working directory: {working_dir}") + with cwd(working_dir): + if "convert" == args.subcommand: + target_dir = args.target or Path(mkdtemp(prefix="arch-keyringctl-")).absolute() + print( + convert( + working_dir=working_dir, + keyring_root=keyring_root, + sources=args.source, + target_dir=target_dir, + name_override=args.name, + ) + ) + elif "import" == args.subcommand: + target_dir = "main" if args.main else "packager" + print( + convert( + working_dir=working_dir, + keyring_root=keyring_root, + sources=args.source, + target_dir=keyring_root / target_dir, + name_override=args.name, + ) + ) + elif "export" == args.subcommand: + result = export( + working_dir=working_dir, + keyring_root=keyring_root, + sources=args.source, + output=args.output, + ) + if result: + print( + result, + end="", + ) + elif "build" == args.subcommand: + build( + working_dir=working_dir, + keyring_root=keyring_root, + target_dir=keyring_root.parent / "build", + ) + elif "list" == args.subcommand: + trust_filter = TrustFilter[args.trust] + list_keyring( + keyring_root=keyring_root, + sources=args.source, + main_keys=args.main, + trust_filter=trust_filter, + ) + elif "inspect" == args.subcommand: + print( + inspect_keyring( + working_dir=working_dir, + keyring_root=keyring_root, + sources=args.source, + ), + end="", + ) + elif "verify" == args.subcommand: + verify( + working_dir=working_dir, + keyring_root=keyring_root, + sources=args.source, + lint_hokey=args.lint_hokey, + lint_sq_keyring=args.lint_sq_keyring, + ) + elif "ci" == args.subcommand: + ci(working_dir=working_dir, keyring_root=keyring_root, project_root=project_root) + elif "check" == args.subcommand: + verify( + working_dir=working_dir, + keyring_root=keyring_root, + sources=[keyring_root], + lint_hokey=False, + lint_sq_keyring=False, + ) + else: + parser.print_help() + + if args.wait: + print("Press [ENTER] to continue") + input() diff --git a/libkeyringctl/git.py b/libkeyringctl/git.py new file mode 100644 index 0000000..f5e6938 --- /dev/null +++ b/libkeyringctl/git.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +from pathlib import Path +from typing import List +from typing import Optional +from typing import Tuple + +from .util import system + + +def git_changed_files( + git_path: Optional[Path] = None, base: Optional[str] = None, paths: Optional[List[Path]] = None +) -> Tuple[List[Path], List[Path], List[Path]]: + """Returns lists of created, deleted and modified files based on diff stats related to a base commit + and optional paths. + + Parameters + ---------- + git_path: Path to the git repository, current directory by default + base: Optional base rev or current index by default + paths: Optional list of paths to take into account, unfiltered by default + + Returns + ------- + Lists of created, deleted and modified paths + """ + cmd = ["git"] + if git_path: + cmd += ["-C", str(git_path)] + cmd += ["--no-pager", "diff", "--color=never", "--summary", "--numstat"] + if base: + cmd += [base] + if paths: + cmd += ["--"] + cmd += [str(path) for path in paths] + + result: str = system(cmd) + + created: List[Path] = [] + deleted: List[Path] = [] + modified: List[Path] = [] + + for line in result.splitlines(): + line = line.strip() + if line.startswith("create"): + created.append(Path(line.split(maxsplit=3)[3])) + continue + if line.startswith("delete"): + deleted.append(Path(line.split(maxsplit=3)[3])) + continue + modified.append(Path(line.split(maxsplit=2)[2])) + + modified = [path for path in modified if path not in created and path not in deleted] + + return created, deleted, modified diff --git a/libkeyringctl/keyring.py b/libkeyringctl/keyring.py new file mode 100644 index 0000000..673a871 --- /dev/null +++ b/libkeyringctl/keyring.py @@ -0,0 +1,1262 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +from collections import defaultdict +from itertools import chain +from logging import debug +from logging import error +from pathlib import Path +from platform import python_version_tuple +from re import match +from shutil import copytree +from tempfile import NamedTemporaryFile +from tempfile import mkdtemp +from typing import Dict + +# NOTE: remove after python 3.8.x is no longer supported upstream +if int(python_version_tuple()[1]) < 9: # pragma: no cover + from typing import Iterable +else: + from collections.abc import Iterable +from typing import List +from typing import Optional +from typing import Set + +from .sequoia import inspect +from .sequoia import keyring_merge +from .sequoia import keyring_split +from .sequoia import latest_certification +from .sequoia import packet_dump_field +from .sequoia import packet_join +from .sequoia import packet_signature_creation_time +from .sequoia import packet_split +from .trust import certificate_trust +from .trust import certificate_trust_from_paths +from .trust import filter_by_trust +from .trust import format_trust_label +from .trust import trust_color +from .types import Color +from .types import Fingerprint +from .types import Trust +from .types import TrustFilter +from .types import Uid +from .types import Username +from .util import contains_fingerprint +from .util import filter_fingerprints_by_trust +from .util import get_cert_paths +from .util import get_fingerprint_from_partial +from .util import simplify_uid +from .util import transform_fd_to_tmpfile + +PACKET_FILENAME_DATETIME_FORMAT: str = "%Y-%m-%d_%H-%M-%S" + + +def is_pgp_fingerprint(string: str) -> bool: + """Returns whether the passed string looks like a PGP (long) fingerprint + + Parameters + ---------- + string: Input to consider as a fingerprint + + Returns + ------- + RWhether string is a fingerprint + """ + if len(string) not in [16, 40]: + return False + return match("^[A-F0-9]+$", string) is not None + + +def transform_username_to_keyring_path(keyring_dir: Path, paths: List[Path]) -> None: + """Mutates the input sources by transforming passed usernames to keyring paths + + Parameters + ---------- + keyring_dir: The directory underneath the username needs to exist + paths: A list of paths to mutate and replace usernames to keyring paths + """ + for index, source in enumerate(paths): + if source.exists(): + continue + packager_source = keyring_dir / source.name + if not packager_source.exists(): + continue + paths[index] = packager_source + + +def transform_fingerprint_to_keyring_path(keyring_root: Path, paths: List[Path]) -> None: + """Mutates the input sources by transforming passed fingerprints to keyring paths + + Parameters + ---------- + keyring_root: The keyring root directory to look up fingerprints in + paths: A list of paths to mutate and replace fingerprints to keyring paths + """ + for index, source in enumerate(paths): + if source.exists(): + continue + if not is_pgp_fingerprint(source.name): + continue + fingerprint_paths = list(keyring_root.glob(f"*/*/*{source.name}")) + if not fingerprint_paths: + continue + paths[index] = fingerprint_paths[0] + + +def convert_pubkey_signature_packet( + packet: Path, + certificate_fingerprint: Fingerprint, + fingerprint_filter: Optional[Set[Fingerprint]], + current_packet_fingerprint: Optional[Fingerprint], + key_revocations: Dict[Fingerprint, Path], + direct_revocations: Dict[Fingerprint, List[Path]], + direct_sigs: Dict[Fingerprint, List[Path]], +) -> None: + """Convert a public key signature packet + + packet: The Path of the packet file to process + certificate_fingerprint: The public key certificate fingerprint + fingerprint_filter: Optional list of fingerprints of PGP public keys that all certifications will be filtered with + current_packet_fingerprint: Optional certificate fingerprint of the current packet + key_revocation: A dictionary of key revocation packets + direct_revocations: A dictionary of direct key revocations + direct_sigs: A dictionary of direct key signatures + """ + + if not current_packet_fingerprint: + raise Exception('missing current packet fingerprint for "{packet.name}"') + + signature_type = packet_dump_field(packet=packet, query="Type") + issuer = get_fingerprint_from_partial( + fingerprint_filter or set(), Fingerprint(packet_dump_field(packet, "Hashed area|Unhashed area.Issuer")) + ) + + if not issuer: + debug(f"failed to resolve partial fingerprint {issuer}, skipping packet") + return + + if not certificate_fingerprint.endswith(issuer): + debug(f"skipping direct key signature because {issuer} is a third-party and not a self signature") + return + + if signature_type == "KeyRevocation": + key_revocations[issuer] = packet + elif signature_type == "DirectKey" or signature_type.endswith("Certification"): + direct_sigs[issuer].append(packet) + elif signature_type == "CertificationRevocation": + direct_revocations[issuer].append(packet) + else: + raise Exception(f"unknown signature type: {signature_type}") + + +def convert_uid_signature_packet( + packet: Path, + current_packet_uid: Optional[Uid], + fingerprint_filter: Optional[Set[Fingerprint]], + certifications: Dict[Uid, Dict[Fingerprint, List[Path]]], + revocations: Dict[Uid, Dict[Fingerprint, List[Path]]], +) -> None: + """Convert a UID signature packet + + packet: The Path of the packet file to process + current_packet_uid: Optional Uid of the current packet + fingerprint_filter: Optional list of fingerprints of PGP public keys that all certifications will be filtered with + certifications: A dictionary containing all certificaions + revocations: A dictionary containing all revocations + """ + + if not current_packet_uid: + raise Exception('missing current packet uid for "{packet.name}"') + + signature_type = packet_dump_field(packet=packet, query="Type") + issuer = get_fingerprint_from_partial( + fingerprint_filter or set(), Fingerprint(packet_dump_field(packet, "Hashed area|Unhashed area.Issuer")) + ) + + if not issuer: + debug(f"failed to resolve partial fingerprint {issuer}, skipping packet") + else: + if signature_type == "CertificationRevocation": + if fingerprint_filter is None or contains_fingerprint(fingerprints=fingerprint_filter, fingerprint=issuer): + revocations[current_packet_uid][issuer].append(packet) + else: + debug(f"The revocation by issuer {issuer} is not appended because it is not in the filter") + elif signature_type.endswith("Certification"): + if fingerprint_filter is None or contains_fingerprint(fingerprints=fingerprint_filter, fingerprint=issuer): + debug(f"The certification by issuer {issuer} is appended as it is found in the filter.") + certifications[current_packet_uid][issuer].append(packet) + else: + debug(f"The certification by issuer {issuer} is not appended because it is not in the filter") + else: + raise Exception(f"unknown signature type: {signature_type}") + + +def convert_subkey_signature_packet( + packet: Path, + certificate_fingerprint: Fingerprint, + current_packet_fingerprint: Optional[Fingerprint], + fingerprint_filter: Optional[Set[Fingerprint]], + subkey_bindings: Dict[Fingerprint, List[Path]], + subkey_revocations: Dict[Fingerprint, List[Path]], +) -> None: + """Convert a subkey signature packet + + packet: The Path of the packet file to process + certificate_fingerprint: The public key certificate fingerprint + current_packet_fingerprint: Optional certificate fingerprint of the current packet + fingerprint_filter: Optional list of fingerprints of PGP public keys that all certifications will be filtered with + subkey_bindings: A dictionary containing all subkey binding signatures + subkey_revocations: A dictionary containing all subkey revocations + """ + + if not current_packet_fingerprint: + raise Exception('missing current packet fingerprint for "{packet.name}"') + + signature_type = packet_dump_field(packet=packet, query="Type") + issuer = get_fingerprint_from_partial( + fingerprint_filter or set(), Fingerprint(packet_dump_field(packet, "Hashed area|Unhashed area.Issuer")) + ) + + if not issuer: + debug(f"failed to resolve partial fingerprint {issuer}, skipping packet") + else: + if issuer != certificate_fingerprint: + raise Exception(f"subkey packet does not belong to {certificate_fingerprint}, issuer: {issuer}") + + if signature_type == "SubkeyBinding": + subkey_bindings[current_packet_fingerprint].append(packet) + elif signature_type == "SubkeyRevocation": + subkey_revocations[current_packet_fingerprint].append(packet) + else: + raise Exception(f"unknown signature type: {signature_type}") + + +def convert_signature_packet( + packet: Path, + current_packet_mode: Optional[str], + certificate_fingerprint: Optional[Fingerprint], + fingerprint_filter: Optional[Set[Fingerprint]], + key_revocations: Dict[Fingerprint, Path], + current_packet_fingerprint: Optional[Fingerprint], + current_packet_uid: Optional[Uid], + direct_revocations: Dict[Fingerprint, List[Path]], + direct_sigs: Dict[Fingerprint, List[Path]], + certifications: Dict[Uid, Dict[Fingerprint, List[Path]]], + revocations: Dict[Uid, Dict[Fingerprint, List[Path]]], + subkey_bindings: Dict[Fingerprint, List[Path]], + subkey_revocations: Dict[Fingerprint, List[Path]], +) -> None: + """Convert a signature packet + + packet: The Path of the packet file to process + certificate_fingerprint: The public key certificate fingerprint + fingerprint_filter: Optional list of fingerprints of PGP public keys that all certifications will be filtered with + key_revocation: A dictionary containing all key revocation packet + current_packet_fingerprint: Optional certificate fingerprint of the current packet + current_packet_uid: Optional Uid of the current packet + direct_revocations: A dictionary of direct key revocations + direct_sigs: A dictionary of direct key signatures + certifications: A dictionary containing all certificaions + revocations: A dictionary containing all revocations + subkey_bindings: A dictionary containing all subkey binding signatures + subkey_revocations: A dictionary containing all subkey revocations + """ + + if not certificate_fingerprint: + raise Exception('missing certificate fingerprint for "{packet.name}"') + + if current_packet_mode == "pubkey": + convert_pubkey_signature_packet( + packet=packet, + certificate_fingerprint=certificate_fingerprint, + fingerprint_filter=fingerprint_filter, + current_packet_fingerprint=current_packet_fingerprint, + key_revocations=key_revocations, + direct_revocations=direct_revocations, + direct_sigs=direct_sigs, + ) + elif current_packet_mode == "uid": + convert_uid_signature_packet( + packet=packet, + current_packet_uid=current_packet_uid, + fingerprint_filter=fingerprint_filter, + certifications=certifications, + revocations=revocations, + ) + elif current_packet_mode == "subkey": + convert_subkey_signature_packet( + packet=packet, + current_packet_fingerprint=current_packet_fingerprint, + certificate_fingerprint=certificate_fingerprint, + fingerprint_filter=fingerprint_filter, + subkey_bindings=subkey_bindings, + subkey_revocations=subkey_revocations, + ) + elif current_packet_mode == "uattr": + # ignore user attributes and related signatures + debug("skipping user attribute signature packet") + else: + raise Exception(f'unknown signature root for "{packet.name}"') + + +def clean_keyring(keyring: Path) -> None: + """Clean the keyring by f.e. removing old obsolete certifications with matching revocations. + + Parameters + ---------- + keyring: Root directory of the keyring containing all keys to clean. + """ + for cert in get_cert_paths(paths=[keyring]): + for uid in (cert / "uid").iterdir(): + certifications = uid / "certification" + revocations = uid / "revocation" + if not certifications.exists() or not revocations.exists(): + continue + for revocation in revocations.iterdir(): + certification = certifications / revocation.name + if certification.exists(): + debug(f"Cleaning up old certification {certification} for revocation {revocation}") + certification.unlink() + + +def convert_certificate( + working_dir: Path, + certificate: Path, + keyring_dir: Path, + name_override: Optional[Username] = None, + fingerprint_filter: Optional[Set[Fingerprint]] = None, +) -> Path: + """Convert a single file public key certificate into a decomposed directory structure of multiple PGP packets + + The output directory structure is created per user. The username is derived from the certificate via + `derive_username_from_fingerprint` or overridden via `name_override`. + Below the username directory a directory tree describes the public keys components split up into certifications + and revocations, as well as per subkey and per uid certifications and revocations. + + Parameters + ---------- + working_dir: The path of the working directory below which to create split certificates + certificate: The path to a public key certificate + keyring_dir: The path of the keyring used to try to derive the username from the public key fingerprint + name_override: An optional string to override the username in the to be created output directory structure + fingerprint_filter: Optional list of fingerprints of PGP public keys that all certifications will be filtered with + + Raises + ------ + Exception: If required PGP packets are not found + + Returns + ------- + The path of the key directory (which is located below working_dir below the user_dir) + """ + + # root packets + certificate_fingerprint: Optional[Fingerprint] = None + pubkey: Optional[Path] = None + key_revocations: Dict[Fingerprint, Path] = {} + direct_sigs: Dict[Fingerprint, List[Path]] = defaultdict(list) + direct_revocations: Dict[Fingerprint, List[Path]] = defaultdict(list) + + # subkey packets + subkeys: Dict[Fingerprint, Path] = {} + subkey_bindings: Dict[Fingerprint, List[Path]] = defaultdict(list) + subkey_revocations: Dict[Fingerprint, List[Path]] = defaultdict(list) + + # uid packets + uids: Dict[Uid, Path] = {} + certifications: Dict[Uid, Dict[Fingerprint, List[Path]]] = defaultdict(lambda: defaultdict(list)) + revocations: Dict[Uid, Dict[Fingerprint, List[Path]]] = defaultdict(lambda: defaultdict(list)) + + # intermediate variables + current_packet_mode: Optional[str] = None + current_packet_fingerprint: Optional[Fingerprint] = None + current_packet_uid: Optional[Uid] = None + + debug(f"Processing certificate {certificate}") + + for packet in packet_split(working_dir=working_dir, certificate=certificate): + debug(f"Processing packet {packet.name}") + if packet.name.endswith("--PublicKey"): + current_packet_mode = "pubkey" + current_packet_fingerprint = Fingerprint(packet_dump_field(packet, "Fingerprint")) + current_packet_uid = None + + certificate_fingerprint = current_packet_fingerprint + pubkey = packet + elif packet.name.endswith("--UserID"): + current_packet_mode = "uid" + current_packet_fingerprint = None + current_packet_uid = Uid(packet_dump_field(packet, "Value")) + + if current_packet_uid in uids: + raise Exception( + f"Duplicate User ID {current_packet_uid} used in packet {uids[current_packet_uid]} and {packet}" + ) + uids[current_packet_uid] = packet + elif packet.name.endswith("UserAttribute"): + current_packet_mode = "uattr" + current_packet_fingerprint = None + current_packet_uid = None + elif packet.name.endswith("--PublicSubkey"): + current_packet_mode = "subkey" + current_packet_fingerprint = Fingerprint(packet_dump_field(packet, "Fingerprint")) + current_packet_uid = None + + subkeys[current_packet_fingerprint] = packet + elif packet.name.endswith("--SecretKey"): + error( + "\n###################################################################\n" + "Do not ever process your private key file!\n" + "Consider using a hardware token instead of local private key files!\n" + "###################################################################" + ) + raise Exception("Secret key detected, aborting") + elif packet.name.endswith("--Signature"): + convert_signature_packet( + packet=packet, + current_packet_mode=current_packet_mode, + certificate_fingerprint=certificate_fingerprint, + fingerprint_filter=fingerprint_filter, + current_packet_fingerprint=current_packet_fingerprint, + current_packet_uid=current_packet_uid, + key_revocations=key_revocations, + direct_revocations=direct_revocations, + direct_sigs=direct_sigs, + certifications=certifications, + revocations=revocations, + subkey_bindings=subkey_bindings, + subkey_revocations=subkey_revocations, + ) + else: + raise Exception(f'unknown packet type "{packet.name}"') + + if not certificate_fingerprint or not pubkey: + raise Exception("missing certificate public key") + + name_override = ( + name_override + or derive_username_from_fingerprint(keyring_dir=keyring_dir, certificate_fingerprint=certificate_fingerprint) + or Username(certificate.stem) + ) + + user_dir = working_dir / name_override + key_dir = user_dir / certificate_fingerprint + key_dir.mkdir(parents=True, exist_ok=True) + + persist_key_material( + key_dir=key_dir, + direct_sigs=direct_sigs, + direct_revocations=direct_revocations, + certificate_fingerprint=certificate_fingerprint, + pubkey=pubkey, + key_revocations=key_revocations, + subkeys=subkeys, + subkey_bindings=subkey_bindings, + subkey_revocations=subkey_revocations, + uids=uids, + certifications=certifications, + revocations=revocations, + ) + + return key_dir + + +def persist_key_material( + key_dir: Path, + direct_sigs: Dict[Fingerprint, List[Path]], + direct_revocations: Dict[Fingerprint, List[Path]], + certificate_fingerprint: Fingerprint, + pubkey: Path, + key_revocations: Dict[Fingerprint, Path], + subkeys: Dict[Fingerprint, Path], + subkey_bindings: Dict[Fingerprint, List[Path]], + subkey_revocations: Dict[Fingerprint, List[Path]], + uids: Dict[Uid, Path], + certifications: Dict[Uid, Dict[Fingerprint, List[Path]]], + revocations: Dict[Uid, Dict[Fingerprint, List[Path]]], +) -> None: + """Persist the key material found in a certificate to decomposed directory structure + + key_dir: The Path below which to create a decomposed directory structure for the certificate + direct_sigs: A dictionary of direct key signatures + direct_revocations: A dictionary of direct key revocations + certificate_fingerprint: The public key certificate fingerprint + pubkey: The Path of the PGP packet representing the public key material + key_revocations: A dictionary containing all key revocations + subkeys: A dictionary of Paths per Fingerprint that represent the subkey material of the certificate + subkey_bindings: A dictionary containing all subkey binding signatures + subkey_revocations: A dictionary containing all subkey revocations + uids: A dictionary of Path per Uid, that represent the UID packets of the certificate + certifications: A dictionary containing all certificaions + revocations: A dictionary containing all revocations + """ + + persist_public_key( + certificate_fingerprint=certificate_fingerprint, + pubkey=pubkey, + key_dir=key_dir, + ) + + persist_key_revocations( + key_dir=key_dir, + key_revocations=key_revocations, + ) + + persist_direct_key_certifications( + direct_key_certifications=direct_sigs, + key_dir=key_dir, + ) + + persist_direct_key_revocations( + direct_key_revocations=direct_revocations, + key_dir=key_dir, + ) + + persist_subkeys( + key_dir=key_dir, + subkeys=subkeys, + ) + + persist_subkey_bindings( + key_dir=key_dir, + subkey_bindings=subkey_bindings, + issuer=certificate_fingerprint, + ) + + persist_subkey_revocations( + key_dir=key_dir, + subkey_revocations=subkey_revocations, + issuer=certificate_fingerprint, + ) + + persist_uids( + key_dir=key_dir, + uids=uids, + ) + + persist_uid_certifications( + certifications=certifications, + revocations=revocations, + key_dir=key_dir, + ) + + persist_uid_revocations( + revocations=revocations, + key_dir=key_dir, + ) + + +def persist_public_key( + certificate_fingerprint: Fingerprint, + pubkey: Path, + key_dir: Path, +) -> None: + """Persist the Public-Key packet + + Parameters + ---------- + certificate_fingerprint: The unique fingerprint of the public key + pubkey: The path to the public key of the root key + key_dir: The root directory below which the basic key material is persisted + """ + + packets: List[Path] = [pubkey] + output_file = key_dir / f"{certificate_fingerprint}.asc" + output_file.parent.mkdir(parents=True, exist_ok=True) + debug(f"Writing file {output_file} from {[str(packet) for packet in packets]}") + packet_join(packets, output_file, force=True) + + +def persist_uids( + key_dir: Path, + uids: Dict[Uid, Path], +) -> None: + """Persist the User IDs that belong to a PublicKey + + The User ID material consists of a single User ID Packet. + The files are written to a UID specific directory and file below key_dir/uid. + + Parameters + ---------- + key_dir: The root directory below which the basic key material is persisted + uids: The User IDs of a Public-Key (the root key) + """ + + for uid, uid_packet in uids.items(): + simplified_uid = simplify_uid(uid) + output_file = key_dir / "uid" / simplified_uid / f"{simplified_uid}.asc" + output_file.parent.mkdir(parents=True, exist_ok=True) + debug(f"Writing file {output_file} from {uid_packet}") + packet_join(packets=[uid_packet], output=output_file, force=True) + + +def persist_subkeys( + key_dir: Path, + subkeys: Dict[Fingerprint, Path], +) -> None: + """Persist all Public-Subkeys of a root key file to file(s) + + Parameters + ---------- + key_dir: The root directory below which the basic key material is persisted + subkeys: The PublicSubkeys of a key + """ + + for fingerprint, subkey in subkeys.items(): + output_file = key_dir / "subkey" / fingerprint / f"{fingerprint}.asc" + output_file.parent.mkdir(parents=True, exist_ok=True) + debug(f"Writing file {output_file} from {str(subkey)}") + packet_join(packets=[subkey], output=output_file, force=True) + + +def persist_subkey_bindings( + key_dir: Path, + subkey_bindings: Dict[Fingerprint, List[Path]], + issuer: Fingerprint, +) -> None: + """Persist all SubkeyBinding of a root key file to file(s) + + Parameters + ---------- + key_dir: The root directory below which the basic key material is persisted + subkey_bindings: The SubkeyBinding signatures of a Public-Subkey + issuer: Fingerprint of the issuer + """ + + for fingerprint, bindings in subkey_bindings.items(): + subkey_binding = latest_certification(bindings) + output_file = key_dir / "subkey" / fingerprint / "certification" / f"{issuer}.asc" + output_file.parent.mkdir(parents=True, exist_ok=True) + debug(f"Writing file {output_file} from {str(subkey_binding)}") + packet_join(packets=[subkey_binding], output=output_file, force=True) + + +def persist_subkey_revocations( + key_dir: Path, + subkey_revocations: Dict[Fingerprint, List[Path]], + issuer: Fingerprint, +) -> None: + """Persist the SubkeyRevocations of all Public-Subkeys of a root key to file(s) + + Parameters + ---------- + key_dir: The root directory below which the basic key material is persisted + subkey_revocations: The SubkeyRevocations of PublicSubkeys of a key + issuer: Fingerprint of the issuer + """ + + for fingerprint, revocations in subkey_revocations.items(): + revocation = latest_certification(revocations) + output_file = key_dir / "subkey" / fingerprint / "revocation" / f"{issuer}.asc" + output_file.parent.mkdir(parents=True, exist_ok=True) + debug(f"Writing file {output_file} from {revocation}") + packet_join(packets=[revocation], output=output_file, force=True) + + +def persist_key_revocations( + key_revocations: Dict[Fingerprint, Path], + key_dir: Path, +) -> None: + """Persist the key revocation + + Parameters + ---------- + key_revocations: Dictionary with key revocation + key_dir: The root directory below which the revocation is persisted + """ + + for issuer, revocation in key_revocations.items(): + output_file = key_dir / "revocation" / f"{issuer}.asc" + output_file.parent.mkdir(parents=True, exist_ok=True) + + debug(f"Writing file {output_file} from {str(revocation)}") + packet_join(packets=[revocation], output=output_file, force=True) + + +def persist_direct_key_certifications( + direct_key_certifications: Dict[Fingerprint, List[Path]], + key_dir: Path, +) -> None: + """Persist the signatures directly on a root key (such as DirectKeys or *Certifications without a User ID) to + file(s) + + Parameters + ---------- + direct_key_certifications: The direct key certifications to write to file + key_dir: The root directory below which the Directkeys are persisted + """ + + for issuer, certifications in direct_key_certifications.items(): + output_dir = key_dir / "directkey" / "certification" / issuer + output_dir.mkdir(parents=True, exist_ok=True) + + for certification in certifications: + creation_time = packet_signature_creation_time(certification).strftime(PACKET_FILENAME_DATETIME_FORMAT) + output_file = output_dir / f"{creation_time}.asc" + debug(f"Writing file {output_file} from {str(certification)}") + packet_join(packets=[certification], output=output_file, force=True) + + +def persist_direct_key_revocations( + direct_key_revocations: Dict[Fingerprint, List[Path]], + key_dir: Path, +) -> None: + """Persist the revocations directly on a root key (such as KeyRevocation) to file(s) + + Parameters + ---------- + direct_key_revocations: The direct key revocations to write to file + key_dir: The root directory below which the Directkeys are persisted + """ + + for issuer, certifications in direct_key_revocations.items(): + output_dir = key_dir / "directkey" / "revocation" / issuer + output_dir.mkdir(parents=True, exist_ok=True) + + for certification in certifications: + creation_time = packet_signature_creation_time(certification).strftime(PACKET_FILENAME_DATETIME_FORMAT) + output_file = output_dir / f"{creation_time}.asc" + debug(f"Writing file {output_file} from {str(certification)}") + packet_join(packets=[certification], output=output_file, force=True) + + +def persist_uid_certifications( + certifications: Dict[Uid, Dict[Fingerprint, List[Path]]], + revocations: Dict[Uid, Dict[Fingerprint, List[Path]]], + key_dir: Path, +) -> None: + """Persist the certifications of a root key to file(s) + + The certifications include all CasualCertifications, GenericCertifications, PersonaCertifications and + PositiveCertifications for all User IDs of the given root key. + All certifications are persisted in per User ID certification directories below key_dir. + + Certifications that have a matching revocation are skipped to match behavior of import-clean. + + Parameters + ---------- + certifications: The certifications to write to file + revocations: The revocations to check against if certifications need to be persisted. + key_dir: The root directory below which certifications are persisted + """ + + for uid, uid_certifications in certifications.items(): + for issuer, issuer_certifications in uid_certifications.items(): + # skip certifications if there is a revocation present to match import-clean behavior + if uid in revocations and issuer in revocations[uid] and revocations[uid][issuer]: + continue + certification_dir = key_dir / "uid" / simplify_uid(uid) / "certification" + certification_dir.mkdir(parents=True, exist_ok=True) + certification = latest_certification(issuer_certifications) + output_file = certification_dir / f"{issuer}.asc" + debug(f"Writing file {output_file} from {certification}") + packet_join(packets=[certification], output=output_file, force=True) + + +def persist_uid_revocations( + revocations: Dict[Uid, Dict[Fingerprint, List[Path]]], + key_dir: Path, +) -> None: + """Persist the revocations of a root key to file(s) + + The revocations include all CertificationRevocations for all User IDs of the given root key. + All revocations are persisted in per User ID 'revocation' directories below key_dir. + + Parameters + ---------- + revocations: The revocations to write to file + key_dir: The root directory below which revocations will be persisted + """ + + for uid, uid_revocations in revocations.items(): + for issuer, issuer_revocations in uid_revocations.items(): + revocation_dir = key_dir / "uid" / simplify_uid(uid) / "revocation" + revocation_dir.mkdir(parents=True, exist_ok=True) + revocation = latest_certification(issuer_revocations) + output_file = revocation_dir / f"{issuer}.asc" + debug(f"Writing file {output_file} from {revocation}") + packet_join(packets=[revocation], output=output_file, force=True) + + +def derive_username_from_fingerprint(keyring_dir: Path, certificate_fingerprint: Fingerprint) -> Optional[Username]: + """Attempt to derive the username of a public key fingerprint from a keyring directory + + Parameters + ---------- + keyring_dir: The directory in which to look up a username + certificate_fingerprint: The public key fingerprint to derive the username from + + Raises + ------ + Exception: If more than one username is found (a public key can only belong to one individual) + + Returns + ------- + A string representing the username a public key certificate belongs to, None otherwise + """ + + matches = list(keyring_dir.glob(f"*/*{certificate_fingerprint}")) + + if len(matches) > 1: + raise Exception( + f"More than one username found in {keyring_dir} when probing for fingerprint '{certificate_fingerprint}': " + f"{matches}" + ) + elif not matches: + debug(f"Can not derive username from target directory for fingerprint {certificate_fingerprint}") + return None + else: + username = matches[0].parent.stem + debug( + f"Successfully derived username '{username}' from target directory for fingerprint " + f"{certificate_fingerprint}" + ) + return Username(username) + + +def convert( + working_dir: Path, + keyring_root: Path, + sources: List[Path], + target_dir: Path, + name_override: Optional[Username] = None, +) -> Path: + """Convert a path containing PGP certificate material to a decomposed directory structure + + Any input is first split by `keyring_split()` into individual certificates. + + Parameters + ---------- + working_dir: A directory to use for temporary files + keyring_root: The keyring root directory to look up accepted fingerprints for certifications + sources: A path to a file or directory to decompose + target_dir: A directory path to write the new directory structure to + name_override: An optional username override for the call to `convert_certificate()` + + Returns + ------- + The directory that contains the resulting directory structure (target_dir) + """ + + directories: List[Path] = [] + transform_fd_to_tmpfile(working_dir=working_dir, sources=sources) + keys: Iterable[Path] = set(chain.from_iterable(map(lambda s: s.iterdir() if s.is_dir() else [s], sources))) + + fingerprint_filter = set( + get_fingerprints( + working_dir=working_dir, + sources=sources, + paths=[keyring_root] if keyring_root.exists() else [], + ).keys() + ) + + for key in keys: + for cert in keyring_split(working_dir=working_dir, keyring=key, preserve_filename=True): + directories.append( + convert_certificate( + working_dir=working_dir, + certificate=cert, + keyring_dir=target_dir, + name_override=name_override, + fingerprint_filter=fingerprint_filter, + ) + ) + + for path in directories: + user_dir = path.parent + (target_dir / user_dir.name).mkdir(parents=True, exist_ok=True) + copytree(src=user_dir, dst=(target_dir / user_dir.name), dirs_exist_ok=True) + + clean_keyring(keyring=target_dir) + + return target_dir + + +def export_ownertrust(certs: List[Path], keyring_root: Path, output: Path) -> List[Fingerprint]: + """Export ownertrust from a set of keys and return the trusted and revoked fingerprints + + The output file format is compatible with `gpg --import-ownertrust` and lists the main fingerprint ID of all + non-revoked keys as fully trusted. + The exported file is used by pacman-key when importing a keyring (see + https://man.archlinux.org/man/pacman-key.8#PROVIDING_A_KEYRING_FOR_IMPORT). + + Parameters + ---------- + certs: The certificates to trust + keyring_root: The keyring root directory to get all accepted fingerprints from + output: The file path to write to + + Returns + ------- + List of ownertrust fingerprints + """ + + main_trusts = certificate_trust_from_paths( + sources=certs, + main_keys=get_fingerprints_from_paths(sources=certs), + all_fingerprints=get_fingerprints_from_paths([keyring_root]), + ) + trusted_certs: List[Fingerprint] = filter_fingerprints_by_trust(main_trusts, Trust.full) + + with open(file=output, mode="w") as trusted_certs_file: + for cert in sorted(set(trusted_certs)): + debug(f"Writing {cert} to {output}") + trusted_certs_file.write(f"{cert}:4:\n") + + return trusted_certs + + +def export_revoked(certs: List[Path], keyring_root: Path, main_keys: Set[Fingerprint], output: Path) -> None: + """Export the PGP revoked status from a set of keys + + The output file contains the fingerprints of all self-revoked keys and all keys for which at least two revocations + by any main key exist. + The exported file is used by pacman-key when importing a keyring (see + https://man.archlinux.org/man/pacman-key.8#PROVIDING_A_KEYRING_FOR_IMPORT). + + Parameters + ---------- + certs: A list of directories with keys to check for their revocation status + keyring_root: The keyring root directory to get all accepted fingerprints from + main_keys: A list of strings representing the fingerprints of (current and/or revoked) main keys + output: The file path to write to + """ + + certificate_trusts = certificate_trust_from_paths( + sources=certs, + main_keys=main_keys, + all_fingerprints=get_fingerprints_from_paths([keyring_root]), + ) + revoked_certs: List[Fingerprint] = filter_fingerprints_by_trust(certificate_trusts, Trust.revoked) + + with open(file=output, mode="w") as revoked_certs_file: + for cert in sorted(set(revoked_certs)): + debug(f"Writing {cert} to {output}") + revoked_certs_file.write(f"{cert}\n") + + +def get_fingerprints_from_keyring_files(working_dir: Path, source: Iterable[Path]) -> Dict[Fingerprint, Username]: + """Get all fingerprints of PGP public keys from import file(s) + + Parameters + ---------- + working_dir: A directory to use for temporary files + source: The path to a source file or directory containing keyrings + + Returns + ------- + A dict of all fingerprints and their usernames of PGP public keys below path + """ + + fingerprints: Dict[Fingerprint, Username] = {} + keys: Iterable[Path] = set(chain.from_iterable(map(lambda s: s.iterdir() if s.is_dir() else [s], source))) + + for key in keys: + for certificate in keyring_split(working_dir=working_dir, keyring=key, preserve_filename=True): + for packet in packet_split(working_dir=working_dir, certificate=certificate): + if packet.name.endswith("--PublicKey"): + fingerprints[Fingerprint(packet_dump_field(packet, "Fingerprint"))] = Username(certificate.stem) + + debug(f"Fingerprints of PGP public keys in {source}: {fingerprints}") + return fingerprints + + +def get_fingerprints_from_certificate_directory( + paths: List[Path], prefix: str = "", postfix: str = "" +) -> Dict[Fingerprint, Username]: + """Get all fingerprints of PGP public keys from decomposed directory structures + + Parameters + ---------- + paths: The path to a decomposed directory structure + prefix: Prefix to add to each username + postfix: Postfix to add to each username + + Returns + ------- + A dict of all fingerprints and their usernames of PGP public keys below path + """ + + fingerprints: Dict[Fingerprint, Username] = {} + for cert in sorted(get_cert_paths(paths)): + fingerprints[Fingerprint(cert.name)] = Username(f"{prefix}{cert.parent.name}{postfix}") + + debug(f"Fingerprints of PGP public keys in {paths}: {fingerprints}") + return fingerprints + + +def get_fingerprints(working_dir: Path, sources: Iterable[Path], paths: List[Path]) -> Dict[Fingerprint, Username]: + """Get the fingerprints of PGP public keys from input paths and decomposed directory structures + + Parameters + ---------- + working_dir: A directory to use for temporary files + sources: A list of directories or files from which to read PGP keyring information + paths: A list of paths that identify decomposed PGP data in directory structures + + Returns + ------- + A dict of all fingerprints and their usernames of PGP public keys below path + """ + + fingerprints: Dict[Fingerprint, Username] = {} + + fingerprints.update( + get_fingerprints_from_keyring_files( + working_dir=working_dir, + source=sources, + ) + ) + + fingerprints.update(get_fingerprints_from_certificate_directory(paths=paths)) + + return fingerprints + + +def get_packets_from_path(path: Path) -> List[Path]: + """Collects packets from one level by appending the root, certifications and revocations. + + Parameters + ---------- + path: Filesystem path used to collect the packets from + + Returns + ------- + A list of packets ordered by root, certification, revocation + """ + + if not path.exists(): + return [] + + packets: List[Path] = [] + packets += sorted(path.glob("*.asc")) + certifications = path / "certification" + revocations = path / "revocation" + packets += sorted(certifications.glob("*.asc")) if certifications.exists() else [] + packets += sorted(revocations.glob("*.asc")) if revocations.exists() else [] + return packets + + +def get_packets_from_listing(path: Path) -> List[Path]: + """Collects packets from a listing of directories holding one level each by calling `get_packets_from_path`. + + Parameters + ---------- + path: Filesystem path used as listing to collect the packets from + + Returns + ------- + A list of packets ordered by root, certification, revocation for each level + """ + if not path.exists(): + return [] + + packets: List[Path] = [] + for sub_path in sorted(path.iterdir()): + packets += get_packets_from_path(sub_path) + return packets + + +def export( + working_dir: Path, + keyring_root: Path, + sources: Optional[List[Path]] = None, + output: Optional[Path] = None, +) -> Optional[str]: + """Export all provided PGP packet files to a single output file + + If sources contains directories, any .asc files below them are considered. + + Parameters + ---------- + working_dir: A directory to use for temporary files + keyring_root: The keyring root directory to look up username shorthand sources + sources: A list of username, fingerprint or directories from which to read PGP packet information + (defaults to `keyring_root`) + output: An output file that all PGP packet data is written to, return the result instead if None + + Returns + ------- + The result if no output file has been used + """ + + if not sources: + sources = [keyring_root] + + # transform shorthand paths to actual keyring paths + transform_username_to_keyring_path(keyring_dir=keyring_root / "packager", paths=sources) + transform_fingerprint_to_keyring_path(keyring_root=keyring_root, paths=sources) + + temp_dir = Path(mkdtemp(dir=working_dir, prefix="arch-keyringctl-export-join-")).absolute() + cert_paths: Set[Path] = get_cert_paths(sources) + certificates: List[Path] = [] + + for cert_dir in sorted(cert_paths): + packets: List[Path] = [] + packets += get_packets_from_path(cert_dir) + packets += get_packets_from_listing(cert_dir / "subkey") + packets += get_packets_from_listing(cert_dir / "uid") + + directkey_path = cert_dir / "directkey" + directkeys = directkey_path.iterdir() if directkey_path.exists() else [] + for path in directkeys: + packets += get_packets_from_listing(path) + + output_path = temp_dir / f"{cert_dir.name}.asc" + debug(f"Joining {cert_dir} in {output_path}") + packet_join( + packets=packets, + output=output_path, + force=True, + ) + certificates.append(output_path) + + if not certificates: + return None + + return keyring_merge(certificates, output, force=True) + + +def build( + working_dir: Path, + keyring_root: Path, + target_dir: Path, +) -> None: + """Build keyring PGP artifacts alongside ownertrust and revoked status files + + Parameters + ---------- + working_dir: A directory to use for temporary files + keyring_root: The keyring root directory to build the artifacts from + target_dir: Output directory that all artifacts are written to + """ + + target_dir.mkdir(parents=True, exist_ok=True) + target_dir.touch() + + keyring: Path = target_dir / Path("rad4day.gpg") + export(working_dir=working_dir, keyring_root=keyring_root, output=keyring) + + trusted_main_keys = export_ownertrust( + certs=[keyring_root / "main"], + keyring_root=keyring_root, + output=target_dir / "rad4day-trusted", + ) + export_revoked( + certs=[keyring_root], + keyring_root=keyring_root, + main_keys=set(trusted_main_keys), + output=target_dir / "rad4day-revoked", + ) + + +def list_keyring( + keyring_root: Path, + sources: Optional[List[Path]] = None, + main_keys: bool = False, + trust_filter: TrustFilter = TrustFilter.all, +) -> None: + """List certificates in the keyring + + If sources contains directories, all certificate below them are considered. + + Parameters + ---------- + keyring_root: The keyring root directory to look up username shorthand sources + sources: A list of username, fingerprint or directories from which to read PGP packet information + (defaults to `keyring_root`) + main_keys: List main keys instead of packager keys (defaults to False) + trust_filter: Filter the listing based on trust + """ + + keyring_dir = keyring_root / ("main" if main_keys else "packager") + + if not sources: + sources = list(sorted(keyring_dir.iterdir(), key=lambda path: path.name.casefold())) + + # transform shorthand paths to actual keyring paths + transform_username_to_keyring_path(keyring_dir=keyring_dir, paths=sources) + transform_fingerprint_to_keyring_path(keyring_root=keyring_root, paths=sources) + + # resolve all sources to certificate paths + sources = list(sorted(get_cert_paths(sources), key=lambda path: str(path).casefold())) + + username_length = max([len(source.parent.name) for source in sources]) + for certificate in sources: + username: Username = Username(certificate.parent.name) + trust = certificate_trust( + certificate=certificate, + main_keys=get_fingerprints_from_paths([keyring_root / "main"]), + all_fingerprints=get_fingerprints_from_paths([keyring_root]), + ) + if not filter_by_trust(trust=trust, trust_filter=trust_filter): + continue + trust_label = format_trust_label(trust=trust) + print(f"{username:<{username_length}} {certificate.name} {trust_label}") + + +def inspect_keyring(working_dir: Path, keyring_root: Path, sources: Optional[List[Path]]) -> str: + """Inspect certificates in the keyring and pretty print the data + + If sources contains directories, all certificate below them are considered. + + Parameters + ---------- + working_dir: A directory to use for temporary files + keyring_root: The keyring root directory to look up username shorthand sources + sources: A list of username, fingerprint or directories from which to read PGP packet information + (defaults to `keyring_root`) + + Returns + ------- + The result of the inspect + """ + + if not sources: + sources = [keyring_root] + + # transform shorthand paths to actual keyring paths + transform_username_to_keyring_path(keyring_dir=keyring_root / "packager", paths=sources) + transform_fingerprint_to_keyring_path(keyring_root=keyring_root, paths=sources) + + with NamedTemporaryFile(dir=working_dir, prefix="packet-", suffix=".asc") as keyring: + keyring_path = Path(keyring.name) + export(working_dir=working_dir, keyring_root=keyring_root, sources=sources, output=keyring_path) + + fingerprints: Dict[Fingerprint, Username] = get_fingerprints_from_certificate_directory( + paths=[keyring_root / "packager"] + ) | get_fingerprints_from_certificate_directory( + paths=[keyring_root / "main"], postfix=f" {Color.BOLD.value}(main){Color.RST.value}" + ) + + main_keys = get_fingerprints_from_paths([keyring_root / "main"]) + all_fingerprints = get_fingerprints_from_paths([keyring_root]) + + trusts: Dict[Fingerprint, Trust] = certificate_trust_from_paths( + sources=[keyring_root], main_keys=main_keys, all_fingerprints=all_fingerprints + ) + + for fingerprint in fingerprints.keys(): + trust = trusts[fingerprint] + fingerprints[fingerprint] = Username( + f"{trust_color(trust=trust).value}{fingerprints[fingerprint]} {format_trust_label(trust=trust)}" + ) + + return inspect( + packet=keyring_path, + certifications=True, + fingerprints=fingerprints, + ) + + +def get_fingerprints_from_paths(sources: Iterable[Path]) -> Set[Fingerprint]: + """Get the fingerprints of all certificates found in the sources paths. + + Parameters + ---------- + sources: A list of directories from which to get fingerprints of the certificates. + + Returns + ------- + The list of all fingerprints obtained from the sources. + """ + return set([Fingerprint(cert.name) for cert in get_cert_paths(sources)]) diff --git a/libkeyringctl/sequoia.py b/libkeyringctl/sequoia.py new file mode 100644 index 0000000..941f5e0 --- /dev/null +++ b/libkeyringctl/sequoia.py @@ -0,0 +1,363 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +from collections import deque +from datetime import datetime +from functools import reduce +from pathlib import Path +from platform import python_version_tuple +from re import sub +from tempfile import mkdtemp +from typing import Dict + +# NOTE: remove after python 3.8.x is no longer supported upstream +if int(python_version_tuple()[1]) < 9: # pragma: no cover + from typing import Iterable +else: + from collections.abc import Iterable +from typing import List +from typing import Optional + +from .types import Fingerprint +from .types import PacketKind +from .types import Uid +from .types import Username +from .util import cwd +from .util import natural_sort_path +from .util import system + + +def keyring_split(working_dir: Path, keyring: Path, preserve_filename: bool = False) -> Iterable[Path]: + """Split a file containing a PGP keyring into separate certificate files + + The original keyring filename is preserved if the split only yields a single certificate. + If preserve_filename is True, all keyrings are placed into separate directories while preserving + the filename. + + The file is split using sq. + + Parameters + ---------- + working_dir: The path of the working directory below which to create the output files + keyring: The path of a file containing a PGP keyring + preserve_filename: If True, all keyrings are placed into separate directories while preserving the filename + + Returns + ------- + An iterable over the naturally sorted list of certificate files derived from a keyring + """ + + keyring_dir = Path(mkdtemp(dir=working_dir, prefix="keyring-")).absolute() + + with cwd(keyring_dir): + system(["sq", "keyring", "split", "--prefix", "", str(keyring)]) + + keyrings: List[Path] = list(natural_sort_path(keyring_dir.iterdir())) + + if 1 == len(keyrings) or preserve_filename: + for index, key in enumerate(keyrings): + keyring_sub_dir = Path(mkdtemp(dir=keyring_dir, prefix=f"{keyring.name}-")).absolute() + keyrings[index] = key.rename(keyring_sub_dir / keyring.name) + + return keyrings + + +def keyring_merge(certificates: List[Path], output: Optional[Path] = None, force: bool = False) -> str: + """Merge multiple certificates into a keyring + + Parameters + ---------- + certificates: List of paths to certificates to merge into a keyring + output: Path to a file which the keyring is written, return the result instead if None + force: Whether to force overwriting existing files (defaults to False) + + Returns + ------- + The result if no output file has been used + """ + + cmd = ["sq", "keyring", "merge"] + if force: + cmd.insert(1, "--force") + if output: + cmd += ["--output", str(output)] + cmd += [str(cert) for cert in sorted(certificates)] + + return system(cmd) + + +def packet_split(working_dir: Path, certificate: Path) -> Iterable[Path]: + """Split a file containing a PGP certificate into separate packet files + + The files are split using sq + + Parameters + ---------- + working_dir: The path of the working directory below which to create the output files + certificate: The absolute path of a file containing one PGP certificate + + Returns + ------- + An iterable over the naturally sorted list of packet files derived from certificate + """ + + packet_dir = Path(mkdtemp(dir=working_dir, prefix="packet-")).absolute() + + with cwd(packet_dir): + system(["sq", "packet", "split", "--prefix", "", str(certificate)]) + return natural_sort_path(packet_dir.iterdir()) + + +def packet_join(packets: List[Path], output: Optional[Path] = None, force: bool = False) -> str: + """Join PGP packet data in files to a single output file + + Parameters + ---------- + packets: A list of paths to files that contain PGP packet data + output: Path to a file to which all PGP packet data is written, return the result instead if None + force: Whether to force overwriting existing files (defaults to False) + + Returns + ------- + The result if no output file has been used + """ + + cmd = ["sq", "packet", "join"] + if force: + cmd.insert(1, "--force") + packets_str = list(map(lambda path: str(path), packets)) + cmd.extend(packets_str) + cmd.extend(["--output", str(output)]) + return system(cmd) + + +def inspect( + packet: Path, certifications: bool = True, fingerprints: Optional[Dict[Fingerprint, Username]] = None +) -> str: + """Inspect PGP packet data and return the result + + Parameters + ---------- + packet: Path to a file that contain PGP data + certifications: Whether to print third-party certifications + fingerprints: Optional dict of fingerprints to usernames to enrich the output with + + Returns + ------- + The result of the inspection + """ + + cmd = ["sq", "inspect"] + if certifications: + cmd.append("--certifications") + cmd.append(str(packet)) + result: str = system(cmd) + + if fingerprints: + for fingerprint, username in fingerprints.items(): + result = sub(f"{fingerprint}", f"{fingerprint} {username}", result) + result = sub(f" {fingerprint[24:]}", f" {fingerprint[24:]} {username}", result) + + return result + + +def packet_dump(packet: Path) -> str: + """Dump a PGP packet to string + + The `sq packet dump` command is used to retrieve a dump of information from a PGP packet + + Parameters + ---------- + packet: The path to the PGP packet to retrieve the value from + + Returns + ------- + The contents of the packet dump + """ + + return system(["sq", "packet", "dump", str(packet)]) + + +def packet_dump_field(packet: Path, query: str) -> str: + """Retrieve the value of a field from a PGP packet + + Field queries are possible with the following notation during tree traversal: + - Use '.' to separate the parent section + - Use '*' as a wildcard for the current section + - Use '|' inside the current level as a logical OR + + Example: + - Version + - Hashed area|Unhashed area.Issuer + - *.Issuer + + Parameters + ---------- + packet: The path to the PGP packet to retrieve the value from + query: The name of the field as a query notation + + Raises + ------ + Exception: If the field is not found in the PGP packet + + Returns + ------- + The value of the field found in packet + """ + + dump = packet_dump(packet) + + queries = deque(query.split(".")) + path = [queries.popleft()] + depth = 0 + + # remove leading 4 space indention + lines = list(filter(lambda line: line.startswith(" "), dump.splitlines())) + lines = [sub(r"^ {4}", "", line, count=1) for line in lines] + # filter empty lines + lines = list(filter(lambda line: line.strip(), lines)) + + for line in lines: + # determine current line depth by counting whitespace pairs + depth_line = int((len(line) - len(line.lstrip(" "))) / 2) + line = line.lstrip(" ") + + # skip nodes that are deeper as our currently matched path + if depth < depth_line: + continue + + # unwind the current query path until reaching previous match depth + while depth > depth_line: + queries.appendleft(path.pop()) + depth -= 1 + matcher = path[-1].split("|") + + # check if current field matches the query expression + field = line.split(sep=":", maxsplit=1)[0] + if field not in matcher and "*" not in matcher: + continue + + # next depth is one level deeper as the current line + depth = depth_line + 1 + + # check if matcher is not the leaf of the query expression + if queries: + path.append(queries.popleft()) + continue + + # return final match + return line.split(sep=": ", maxsplit=1)[1] if ": " in line else line + + raise Exception(f"Packet '{packet}' did not match the query '{query}'") + + +def packet_signature_creation_time(packet: Path) -> datetime: + """Retrieve the signature creation time field as datetime + + Parameters + ---------- + packet: The path to the PGP packet to retrieve the value from + + Returns + ------- + The signature creation time as datetime + """ + field = packet_dump_field(packet, "Hashed area.Signature creation time") + field = " ".join(field.split(" ", 3)[0:3]) + return datetime.strptime(field, "%Y-%m-%d %H:%M:%S %Z") + + +def packet_kinds(packet: Path) -> List[PacketKind]: + """Retrieve the PGP packet types of a packet path + + Parameters + ---------- + packet: The path to the PGP packet to retrieve the kind of + + Returns + ------- + The kind of PGP packet + """ + + dump = packet_dump(packet) + lines = [line for line in dump.splitlines()] + lines = list( + filter(lambda line: not line.startswith(" ") and not line.startswith("WARNING") and line.strip(), lines) + ) + return [PacketKind(line.split()[0]) for line in lines] + + +def latest_certification(certifications: Iterable[Path]) -> Path: + """Returns the latest certification based on the signature creation time from a list of packets. + + Parameters + ---------- + certifications: List of certification from which to choose the latest from + + Returns + ------- + The latest certification from a list of packets + """ + return reduce( + lambda a, b: a if packet_signature_creation_time(a) > packet_signature_creation_time(b) else b, + certifications, + ) + + +def key_generate(uids: List[Uid], outfile: Path) -> str: + """Generate a PGP key with specific uids + + Parameters + ---------- + uids: List of uids that the key should have + outfile: Path to the file to which the key should be written to + + Returns + ------- + The result of the key generate call + """ + + cmd = ["sq", "key", "generate"] + for uid in uids: + cmd.extend(["--userid", str(uid)]) + cmd.extend(["--export", str(outfile)]) + return system(cmd) + + +def key_extract_certificate(key: Path, output: Optional[Path]) -> str: + """Extracts the non secret part from a key into a certificate + + Parameters + ---------- + key: Path to a file that contain secret key material + output: Path to the file to which the key should be written to, stdout if None + + Returns + ------- + The result of the extract in case output is None + """ + + cmd = ["sq", "key", "extract-cert", str(key)] + if output: + cmd.extend(["--output", str(output)]) + return system(cmd) + + +def certify(key: Path, certificate: Path, uid: Uid, output: Optional[Path]) -> str: + """Inspect PGP packet data and return the result + + Parameters + ---------- + key: Path to a file that contain secret key material + certificate: Path to a certificate file whose uid should be certified + uid: Uid contain in the certificate that should be certified + output: Path to the file to which the key should be written to, stdout if None + + Returns + ------- + The result of the certification in case output is None + """ + + cmd = ["sq", "certify", str(key), str(certificate), uid] + if output: + cmd.extend(["--output", str(output)]) + return system(cmd) diff --git a/libkeyringctl/trust.py b/libkeyringctl/trust.py new file mode 100644 index 0000000..7911b60 --- /dev/null +++ b/libkeyringctl/trust.py @@ -0,0 +1,273 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +from logging import debug +from pathlib import Path +from typing import Dict +from typing import Iterable +from typing import Optional +from typing import Set + +from .types import Color +from .types import Fingerprint +from .types import Trust +from .types import TrustFilter +from .types import Uid +from .util import contains_fingerprint +from .util import get_cert_paths +from .util import get_fingerprint_from_partial + + +def certificate_trust_from_paths( + sources: Iterable[Path], main_keys: Set[Fingerprint], all_fingerprints: Set[Fingerprint] +) -> Dict[Fingerprint, Trust]: + """Get the trust status of all certificates in a list of paths given by main keys. + + Uses `get_get_certificate_trust` to determine the trust status. + + Parameters + ---------- + sources: Certificates to acquire the trust status from + main_keys: Fingerprints of trusted keys used to calculate the trust of the certificates from sources + all_fingerprints: Fingerprints of all certificates, packager and main, to look up key-ids to full fingerprints + + Returns + ------- + A dictionary of fingerprints and their trust level + """ + + sources = get_cert_paths(sources) + certificate_trusts: Dict[Fingerprint, Trust] = {} + + for certificate in sorted(sources): + fingerprint = Fingerprint(certificate.name) + certificate_trusts[fingerprint] = certificate_trust( + certificate=certificate, main_keys=main_keys, all_fingerprints=all_fingerprints + ) + return certificate_trusts + + +def certificate_trust( # noqa: ignore=C901 + certificate: Path, main_keys: Set[Fingerprint], all_fingerprints: Set[Fingerprint] +) -> Trust: + """Get the trust status of a certificates given by main keys. + + main certificates are: + revoked if: + - the certificate has been self-revoked (also applies to 3rd party applied revocation certificates) + full trust if: + - the certificate is not self-revoked + + regular certificates are: + full trust if: + - the certificate is not self-revoked and: + - any uid contains at least 3 non revoked main key signatures + marginal trust if: + - the certificate is not self-revoked and: + - any uid contains at least 1 but less than 3 non revoked main key signatures + - no uid contains at least 3 non revoked main key signatures + unknown trust if: + - the certificate is not self-revoked and: + - no uid contains any non revoked main key signature + revoked if: + - the certificate has been self-revoked, or + - no uid contains at least 3 non revoked main key signatures and: + - any uid contains at least 1 revoked main key signature + + Parameters + ---------- + certificate: Certificate to acquire the trust status from + main_keys: Fingerprints of trusted keys used to calculate the trust of the certificates from sources + all_fingerprints: Fingerprints of all certificates, packager and main, to look up key-ids to full fingerprints + + Returns + ------- + Trust level of the certificate + """ + + fingerprint: Fingerprint = Fingerprint(certificate.name) + keyring_root = certificate.parent.parent.parent + + # collect revoked main keys + main_keys_revoked: Set[Fingerprint] = set() + for main_key in main_keys: + for revocation in keyring_root.glob(f"main/*/{main_key}/revocation/*.asc"): + if main_key.endswith(revocation.stem): + main_keys_revoked.add(main_key) + + revocations: Set[Fingerprint] = set() + # TODO: what about direct key revocations/signatures? + for revocation in certificate.glob("revocation/*.asc"): + issuer: Optional[Fingerprint] = get_fingerprint_from_partial(all_fingerprints, Fingerprint(revocation.stem)) + if not issuer: + raise Exception(f"Unknown issuer: {issuer}") + if not fingerprint.endswith(issuer): + raise Exception(f"Wrong root revocation issuer: {issuer}, expected: {fingerprint}") + debug(f"Revoking {fingerprint} due to self-revocation") + revocations.add(fingerprint) + + if revocations: + return Trust.revoked + + # main keys are either trusted or revoked + is_main_certificate = contains_fingerprint(fingerprints=main_keys, fingerprint=fingerprint) + if is_main_certificate: + return Trust.full + + uid_trust: Dict[Uid, Trust] = {} + self_revoked_uids: Set[Uid] = set() + uids = certificate / "uid" + for uid_path in uids.iterdir(): + uid: Uid = Uid(uid_path.name) + + revocations = set() + for revocation in uid_path.glob("revocation/*.asc"): + issuer = get_fingerprint_from_partial(all_fingerprints, Fingerprint(revocation.stem)) + if not issuer: + raise Exception(f"Unknown issuer: {issuer}") + # self revocation + if fingerprint.endswith(issuer): + self_revoked_uids.add(uid) + # main key revocation + elif contains_fingerprint(fingerprints=main_keys, fingerprint=issuer): + revocations.add(issuer) + + certifications: Set[Fingerprint] = set() + for certification in uid_path.glob("certification/*.asc"): + issuer = get_fingerprint_from_partial(all_fingerprints, Fingerprint(certification.stem)) + if not issuer: + raise Exception(f"Unknown issuer: {issuer}") + # only take main key certifications into account + if not contains_fingerprint(fingerprints=main_keys, fingerprint=issuer): + continue + # do not care about revoked main keys + if contains_fingerprint(fingerprints=main_keys_revoked, fingerprint=issuer): + continue + # do not care about certifications that are revoked + if contains_fingerprint(fingerprints=revocations, fingerprint=issuer): + continue + certifications.add(issuer) + + # self revoked uid + if uid in self_revoked_uids: + debug(f"Certificate {fingerprint} with uid {uid} is self-revoked") + uid_trust[uid] = Trust.revoked + continue + + # full trust + if len(certifications) >= 3: + uid_trust[uid] = Trust.full + continue + + # no full trust and contains revocations + if revocations: + uid_trust[uid] = Trust.revoked + continue + + # marginal trust + if certifications: + uid_trust[uid] = Trust.marginal + continue + + # no trust + uid_trust[uid] = Trust.unknown + + for uid, uid_trust_status in uid_trust.items(): + debug(f"Certificate {fingerprint} with uid {uid} has trust level: {uid_trust_status.name}") + + trust: Trust + # any uid has full trust + if any(map(lambda t: Trust.full == t, uid_trust.values())): + trust = Trust.full + # no uid has full trust but at least one is revoked + elif any(map(lambda e: Trust.revoked == e[1] and e[0] not in self_revoked_uids, uid_trust.items())): + trust = Trust.revoked + # no uid has full trust or is revoked + elif any(map(lambda t: Trust.marginal == t, uid_trust.values())): + trust = Trust.marginal + else: + trust = Trust.unknown + + debug(f"Certificate {fingerprint} has trust level: {trust.name}") + return trust + + +def trust_icon(trust: Trust) -> str: + """Returns a single character icon representing the passed trust status + + Parameters + ---------- + trust: The trust to get an icon for + + Returns + ------- + The single character icon representing the passed trust status + """ + if trust == Trust.revoked: + return "✗" + if trust == Trust.unknown: + return "~" + if trust == Trust.marginal: + return "~" + if trust == Trust.full: + return "✓" + return "?" + + +def trust_color(trust: Trust) -> Color: + """Returns a color representing the passed trust status + + Parameters + ---------- + trust: The trust to get the color of + + Returns + ------- + The color representing the passed trust status + """ + color: Color = Color.RED + if trust == Trust.revoked: + color = Color.RED + if trust == Trust.unknown: + color = Color.YELLOW + if trust == Trust.marginal: + color = Color.YELLOW + if trust == Trust.full: + color = Color.GREEN + return color + + +def format_trust_label(trust: Trust) -> str: + """Formats a given trust status to a text label including color and icon. + + Parameters + ---------- + trust: The trust to get the label for + + Returns + ------- + Text label representing the trust status as literal and icon with colors + """ + return f"{trust_color(trust).value}{trust_icon(trust)} {trust.name}{Color.RST.value}" + + +def filter_by_trust(trust: Trust, trust_filter: TrustFilter) -> bool: + """Filters a trust by a given filter and returns true if within the rules + + Parameters + ---------- + trust: Trust to check for being filtered + trust_filter: Filter rules to check the trust against + + Returns + ------- + True if the given trust is within the filter rules + """ + trust_map = { + TrustFilter.unknown: [Trust.unknown], + TrustFilter.marginal: [Trust.marginal], + TrustFilter.full: [Trust.full], + TrustFilter.revoked: [Trust.revoked], + TrustFilter.unrevoked: [Trust.unknown, Trust.marginal, Trust.full], + TrustFilter.all: [Trust.revoked, Trust.unknown, Trust.marginal, Trust.full], + } + return trust in trust_map[trust_filter] diff --git a/libkeyringctl/types.py b/libkeyringctl/types.py new file mode 100644 index 0000000..98222cd --- /dev/null +++ b/libkeyringctl/types.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +from enum import Enum +from enum import auto +from typing import NewType + +Fingerprint = NewType("Fingerprint", str) +Uid = NewType("Uid", str) +Username = NewType("Username", str) +PacketKind = NewType("PacketKind", str) + + +class Trust(Enum): + unknown = auto + revoked = auto() + marginal = auto() + full = auto() + + +class TrustFilter(Enum): + unknown = "unknown" + revoked = "revoked" + marginal = "marginal" + full = "full" + unrevoked = "unrevoked" + all = "all" + + +TRUST_MAX_LENGTH: int = max([len(e.name) for e in Trust]) + + +class Color(Enum): + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + RST = "\033[0m" + BOLD = "\033[1m" + UNDERLINE = "\033[4m" diff --git a/libkeyringctl/util.py b/libkeyringctl/util.py new file mode 100644 index 0000000..79b95d0 --- /dev/null +++ b/libkeyringctl/util.py @@ -0,0 +1,341 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +from contextlib import contextmanager +from hashlib import sha256 +from os import chdir +from os import environ +from os import getcwd +from pathlib import Path +from platform import python_version_tuple +from re import escape +from re import split +from re import sub +from string import ascii_letters +from string import digits +from subprocess import STDOUT +from subprocess import CalledProcessError +from subprocess import check_output +from sys import exit +from sys import stderr +from tempfile import mkstemp +from traceback import print_stack +from typing import IO +from typing import AnyStr +from typing import Dict + +# NOTE: remove after python 3.8.x is no longer supported upstream +if int(python_version_tuple()[1]) < 9: # pragma: no cover + from typing import Iterable + from typing import Iterator +else: + from collections.abc import Iterable + from collections.abc import Iterator +from typing import List +from typing import Optional +from typing import Set +from typing import Union + +from libkeyringctl.types import Fingerprint +from libkeyringctl.types import Trust +from libkeyringctl.types import Uid + + +@contextmanager +def cwd(new_dir: Path) -> Iterator[None]: + """Change to a new current working directory in a context and go back to the previous dir after the context is done + + Parameters + ---------- + new_dir: A path to change to + """ + + previous_dir = getcwd() + chdir(new_dir) + try: + yield + finally: + chdir(previous_dir) + + +def natural_sort_path(_list: Iterable[Path]) -> Iterable[Path]: + """Sort an Iterable of Paths naturally + + Parameters + ---------- + _list: An iterable containing paths to be sorted + + Return + ------ + An Iterable of paths that are naturally sorted + """ + + def convert_text_chunk(text: str) -> Union[int, str]: + """Convert input text to int or str + + Parameters + ---------- + text: An input string + + Returns + ------- + Either an integer if text is a digit, else text in lower-case representation + """ + + return int(text) if text.isdigit() else text.lower() + + def alphanum_key(key: Path) -> List[Union[int, str]]: + """Retrieve an alphanumeric key from a Path, that can be used in sorted() + + Parameters + ---------- + key: A path for which to create a key + + Returns + ------- + A list of either int or str objects that may serve as 'key' argument for sorted() + """ + + return [convert_text_chunk(c) for c in split("([0-9]+)", str(key.name))] + + return sorted(_list, key=alphanum_key) + + +def system( + cmd: List[str], + _stdin: Optional[IO[AnyStr]] = None, + exit_on_error: bool = False, + env: Optional[Dict[str, str]] = None, +) -> str: + """Execute a command using check_output + + Parameters + ---------- + cmd: A list of strings to be fed to check_output + _stdin: input fd used for the spawned process + exit_on_error: Whether to exit the script when encountering an error (defaults to False) + env: Optional environment vars for the shell invocation + + Raises + ------ + CalledProcessError: If not exit_on_error and `check_output()` encounters an error + + Returns + ------- + The output of cmd + """ + if not env: + env = {"HOME": environ["HOME"], "PATH": environ["PATH"], "LANG": "en_US.UTF-8"} + + try: + return check_output(cmd, stderr=STDOUT, stdin=_stdin, env=env).decode() + except CalledProcessError as e: + stderr.buffer.write(e.stdout) + print_stack() + if exit_on_error: + exit(e.returncode) + raise e + + +def absolute_path(path: str) -> Path: + """Return the absolute path of a given str + + Parameters + ---------- + path: A string representing a path + + Returns + ------- + The absolute path representation of path + """ + + return Path(path).absolute() + + +def transform_fd_to_tmpfile(working_dir: Path, sources: List[Path]) -> None: + """Transforms an input list of paths from any file descriptor of the current process to a tempfile in working_dir. + + Using this function on fd inputs allow to pass the content to another process while hidepid is active and /proc + not visible for the other process. + + Parameters + ---------- + working_dir: A directory to use for temporary files + sources: Paths that should be iterated and all fd's transformed to tmpfiles + """ + for index, source in enumerate(sources): + source_str = str(source) + if source_str.startswith("/proc/self/fd/") or source_str.startswith("/dev/fd/"): + file = mkstemp(dir=working_dir, prefix=f"{source.name}", suffix=".fd")[1] + with open(file, mode="wb") as f: + f.write(source.read_bytes()) + f.flush() + sources[index] = Path(file) + + +def get_cert_paths(paths: Iterable[Path]) -> Set[Path]: + """Walks a list of paths and resolves all discovered certificate paths + + Parameters + ---------- + paths: A list of paths to walk and resolve to certificate paths. + + Returns + ------- + A set of paths to certificates + """ + + # depth first search certificate paths + cert_paths: Set[Path] = set() + visit: List[Path] = list(paths) + while visit: + path = visit.pop() + # this level contains a certificate, abort depth search + if list(path.glob("*.asc")): + cert_paths.add(path) + continue + visit.extend([path for path in path.iterdir() if path.is_dir()]) + return cert_paths + + +def get_parent_cert_paths(paths: Iterable[Path]) -> Set[Path]: + """Walks a list of paths upwards and resolves all discovered parent certificate paths + + Parameters + ---------- + paths: A list of paths to walk and resolve to certificate paths. + + Returns + ------- + A set of paths to certificates + """ + + # depth first search certificate paths + cert_paths: Set[Path] = set() + visit: List[Path] = list(paths) + while visit: + node = visit.pop().parent + # this level contains a certificate, abort depth search + if "keyring" == node.parent.parent.parent.name: + cert_paths.add(node) + continue + visit.append(node) + return cert_paths + + +def contains_fingerprint(fingerprints: Iterable[Fingerprint], fingerprint: Fingerprint) -> bool: + """Returns weather an iterable structure of fingerprints contains a specific fingerprint + + Parameters + ---------- + fingerprints: Iteratable structure of fingerprints that should be searched + fingerprint: Fingerprint to search for + + Returns + ------- + Weather an iterable structure of fingerprints contains a specific fingerprint + """ + + return any(filter(lambda e: str(e).endswith(fingerprint), fingerprints)) + + +def get_fingerprint_from_partial( + fingerprints: Iterable[Fingerprint], fingerprint: Fingerprint +) -> Optional[Fingerprint]: + """Returns the full fingerprint looked up from a partial fingerprint like a key-id + + Parameters + ---------- + fingerprints: Iteratable structure of fingerprints that should be searched + fingerprint: Partial fingerprint to search for + + Returns + ------- + The full fingerprint or None + """ + + for fingerprint in filter(lambda e: str(e).endswith(fingerprint), fingerprints): + return fingerprint + return None + + +def filter_fingerprints_by_trust(trusts: Dict[Fingerprint, Trust], trust: Trust) -> List[Fingerprint]: + """Filters a dict of Fingerprint to Trust by a passed Trust parameter and returns the matching fingerprints. + + Parameters + ---------- + trusts: Dict of Fingerprint to Trust that should be filtered based on the trust parameter + trust: Trust that should be used to filter the trusts dict + + Returns + ------- + The matching fingerprints of the dict filtered by trust + """ + + return list( + map( + lambda item: item[0], + filter(lambda item: trust == item[1], trusts.items()), + ) + ) + + +simple_printable: str = ascii_letters + digits + "_-.+@" +ascii_mapping: Dict[str, str] = { + "àáâãäæąăǎа": "a", + "ćçĉċč": "c", + "ďđ": "d", + "éèêëęēĕėěɇ": "e", + "ĝğġģ": "g", + "ĥħȟ": "h", + "ìíîïĩīĭįıij": "i", + "ĵɉ": "j", + "ķ": "k", + "ł": "l", + "ńņň": "n", + "òóôõöøŏőðȍǿ": "o", + "śș": "s", + "ß": "ss", + "ț": "t", + "úûüȗűȕù": "u", + "ýÿ": "y", + "źż": "z", +} +ascii_mapping_lookup: Dict[str, str] = {} +for key, value in ascii_mapping.items(): + for c in key: + ascii_mapping_lookup[c] = value + ascii_mapping_lookup[c.upper()] = value.upper() + + +def simplify_ascii(_str: str) -> str: + """Simplify a string to contain more filesystem and printable friendly characters + + Parameters + ---------- + _str: A string to simplify (e.g. 'Foobar McFooface ') + + Returns + ------- + The simplified representation of _str + """ + _str = _str.strip("<") + _str = _str.strip(">") + _str = "".join([ascii_mapping_lookup.get(char) or char for char in _str]) + _str = sub("[^" + escape(simple_printable) + "]", "_", _str) + return _str + + +def simplify_uid(uid: Uid, hash_postfix: bool = True) -> str: + """Simplify a uid to contain more filesystem and printable friendly characters with an optional + collision resistant hash postfix. + + Parameters + ---------- + uid: Uid to simplify (e.g. 'Foobar McFooface ') + hash_postfix: Whether to add a hash of the uid as postfix + + Returns + ------- + Simplified str representation of uid + """ + _hash = "" if not hash_postfix else f"_{sha256(uid.encode()).hexdigest()[:8]}" + return f"{simplify_ascii(_str=uid)}{_hash}" diff --git a/libkeyringctl/verify.py b/libkeyringctl/verify.py new file mode 100644 index 0000000..5b7fc7d --- /dev/null +++ b/libkeyringctl/verify.py @@ -0,0 +1,342 @@ +from logging import debug +from pathlib import Path +from subprocess import PIPE +from subprocess import Popen +from tempfile import NamedTemporaryFile +from typing import List +from typing import Optional +from typing import Set + +from libkeyringctl.keyring import export +from libkeyringctl.keyring import get_fingerprints_from_paths +from libkeyringctl.keyring import is_pgp_fingerprint +from libkeyringctl.keyring import transform_fingerprint_to_keyring_path +from libkeyringctl.keyring import transform_username_to_keyring_path +from libkeyringctl.sequoia import packet_dump_field +from libkeyringctl.sequoia import packet_kinds +from libkeyringctl.types import Fingerprint +from libkeyringctl.types import Uid +from libkeyringctl.util import get_cert_paths +from libkeyringctl.util import get_fingerprint_from_partial +from libkeyringctl.util import simplify_uid +from libkeyringctl.util import system + + +def verify( # noqa: ignore=C901 + working_dir: Path, + keyring_root: Path, + sources: Optional[List[Path]], + lint_hokey: bool = True, + lint_sq_keyring: bool = True, +) -> None: + """Verify certificates against modern expectations using sq-keyring-linter and hokey + + Parameters + ---------- + working_dir: A directory to use for temporary files + keyring_root: The keyring root directory to look up username shorthand sources + sources: A list of username, fingerprint or directories from which to read PGP packet information + (defaults to `keyring_root`) + lint_hokey: Whether to run hokey lint + lint_sq_keyring: Whether to run sq-keyring-linter + """ + + if not sources: + sources = [keyring_root] + + # transform shorthand paths to actual keyring paths + transform_username_to_keyring_path(keyring_dir=keyring_root / "packager", paths=sources) + transform_fingerprint_to_keyring_path(keyring_root=keyring_root, paths=sources) + + cert_paths: Set[Path] = get_cert_paths(sources) + all_fingerprints = get_fingerprints_from_paths([keyring_root]) + + for certificate in sorted(cert_paths): + print(f"Verify {certificate.name} owned by {certificate.parent.name}") + + verify_integrity(certificate=certificate, all_fingerprints=all_fingerprints) + + with NamedTemporaryFile( + dir=working_dir, prefix=f"{certificate.parent.name}-{certificate.name}", suffix=".asc" + ) as keyring: + keyring_path = Path(keyring.name) + export( + working_dir=working_dir, + keyring_root=keyring_root, + sources=[certificate], + output=keyring_path, + ) + + if lint_hokey: + keyring_fd = Popen(("sq", "dearmor", f"{str(keyring_path)}"), stdout=PIPE) + print(system(["hokey", "lint"], _stdin=keyring_fd.stdout), end="") + if lint_sq_keyring: + print(system(["sq-keyring-linter", f"{str(keyring_path)}"]), end="") + + +def verify_integrity(certificate: Path, all_fingerprints: Set[Fingerprint]) -> None: # noqa: ignore=C901 + if not is_pgp_fingerprint(certificate.name): + raise Exception(f"Unexpected certificate name for certificate {certificate.name}: {str(certificate)}") + + pubkey = certificate / f"{certificate.name}.asc" + if not pubkey.is_file(): + raise Exception(f"Missing certificate pubkey {certificate.name}: {str(pubkey)}") + + if not list(certificate.glob("uid/*/*.asc")): + raise Exception(f"Missing at least one UID for {certificate.name}") + + # check packet files + for path in certificate.iterdir(): + if path.is_file(): + if path.name != f"{certificate.name}.asc": + raise Exception(f"Unexpected file in certificate {certificate.name}: {str(path)}") + assert_packet_kind(path=path, expected="Public-Key") + assert_filename_matches_packet_fingerprint(path=path, check=certificate.name) + debug(f"OK: {path}") + elif path.is_dir(): + if "revocation" == path.name: + verify_integrity_key_revocations(path=path) + elif "directkey" == path.name: + for directkey in path.iterdir(): + assert_is_dir(path=directkey) + if "certification" == directkey.name: + verify_integrity_direct_key_certifications(path=directkey) + elif "revocation" == directkey.name: + verify_integrity_direct_key_revocations(path=directkey) + else: + raise_unexpected_file(path=directkey) + elif "uid" == path.name: + for uid in path.iterdir(): + assert_is_dir(path=uid) + uid_packet = uid / f"{uid.name}.asc" + assert_is_file(path=uid_packet) + + uid_binding_sig = uid / "certification" / f"{certificate.name}.asc" + uid_revocation_sig = uid / "revocation" / f"{certificate.name}.asc" + if not uid_binding_sig.is_file() and not uid_revocation_sig: + raise Exception(f"Missing uid binding/revocation sig for {certificate.name}: {str(uid)}") + + for uid_path in uid.iterdir(): + if uid_path.is_file(): + if uid_path.name != f"{uid.name}.asc": + raise Exception(f"Unexpected file in certificate {certificate.name}: {str(uid_path)}") + + assert_packet_kind(path=uid_path, expected="User") + + uid_value = simplify_uid(Uid(packet_dump_field(packet=uid_path, query="Value"))) + if uid_value != uid.name: + raise Exception(f"Unexpected uid in file {str(uid_path)}: {uid_value}") + elif not uid_path.is_dir(): + raise Exception(f"Unexpected file type in certificate {certificate.name}: {str(uid_path)}") + elif "certification" == uid_path.name: + for sig in uid_path.iterdir(): + assert_is_file(path=sig) + assert_is_pgp_fingerprint(path=sig, _str=sig.stem) + assert_has_suffix(path=sig, suffix=".asc") + + assert_packet_kind(path=sig, expected="Signature") + assert_signature_type_certification(path=sig) + + issuer = get_fingerprint_from_partial( + fingerprints=all_fingerprints, + fingerprint=Fingerprint( + packet_dump_field(packet=sig, query="Hashed area|Unhashed area.Issuer") + ), + ) + if issuer != sig.stem: + raise Exception(f"Unexpected issuer in file {str(sig)}: {issuer}") + debug(f"OK: {sig}") + elif "revocation" == uid_path.name: + for sig in uid_path.iterdir(): + assert_is_file(path=sig) + assert_is_pgp_fingerprint(path=sig, _str=sig.stem) + assert_has_suffix(path=sig, suffix=".asc") + + assert_packet_kind(path=sig, expected="Signature") + assert_signature_type(path=sig, expected="CertificationRevocation") + + issuer = get_fingerprint_from_partial( + fingerprints=all_fingerprints, + fingerprint=Fingerprint( + packet_dump_field(packet=sig, query="Hashed area|Unhashed area.Issuer") + ), + ) + if issuer != sig.stem: + raise Exception(f"Unexpected issuer in file {str(sig)}: {issuer}") + + certification = uid_path.parent / "certification" / sig.name + if certification.exists(): + raise Exception(f"Certification exists for revocation {str(sig)}: {certification}") + + debug(f"OK: {sig}") + else: + raise Exception(f"Unexpected directory in certificate {certificate.name}: {str(uid_path)}") + debug(f"OK: {uid_path}") + debug(f"OK: {uid}") + elif "subkey" == path.name: + for subkey in path.iterdir(): + assert_is_dir(path=subkey) + assert_is_pgp_fingerprint(path=subkey, _str=subkey.name) + + subkey_packet = subkey / f"{subkey.name}.asc" + assert_is_file(path=subkey_packet) + + subkey_binding_sig = subkey / "certification" / f"{certificate.name}.asc" + subkey_revocation_sig = subkey / "revocation" / f"{certificate.name}.asc" + if not subkey_binding_sig.is_file() and not subkey_revocation_sig: + raise Exception(f"Missing subkey binding/revocation sig for {certificate.name}: {str(subkey)}") + + for subkey_path in subkey.iterdir(): + if subkey_path.is_file(): + if subkey_path.name != f"{subkey.name}.asc": + raise Exception( + f"Unexpected file in certificate {certificate.name}: {str(subkey_path)}" + ) + + assert_packet_kind(path=subkey_path, expected="Public-Subkey") + assert_filename_matches_packet_fingerprint(path=subkey_path, check=subkey_path.stem) + elif not subkey_path.is_dir(): + raise Exception( + f"Unexpected file type in certificate {certificate.name}: {str(subkey_path)}" + ) + elif "certification" == subkey_path.name: + for sig in subkey_path.iterdir(): + assert_is_file(path=sig) + assert_is_pgp_fingerprint(path=sig, _str=sig.stem) + assert_has_suffix(path=sig, suffix=".asc") + + assert_packet_kind(path=sig, expected="Signature") + assert_signature_type(path=sig, expected="SubkeyBinding") + + assert_filename_matches_packet_issuer_fingerprint(path=sig, check=certificate.name) + elif "revocation" == subkey_path.name: + for sig in subkey_path.iterdir(): + assert_is_file(path=sig) + assert_is_pgp_fingerprint(path=sig, _str=sig.stem) + assert_has_suffix(path=sig, suffix=".asc") + + assert_packet_kind(path=sig, expected="Signature") + assert_signature_type(path=sig, expected="SubkeyRevocation") + + assert_filename_matches_packet_issuer_fingerprint(path=sig, check=certificate.name) + else: + raise Exception( + f"Unexpected directory in certificate {certificate.name}: {str(subkey_path)}" + ) + debug(f"OK: {subkey_path}") + else: + raise Exception(f"Unexpected directory in certificate {certificate.name}: {str(path)}") + else: + raise Exception(f"Unexpected file type in certificate {certificate.name}: {str(path)}") + + +def assert_packet_kind(path: Path, expected: str) -> None: + kinds = packet_kinds(packet=path) + if not kinds or len(kinds) != 1: + raise Exception(f"Unexpected amount of packets in file {str(path)}: {kinds}") + kind = kinds[0] + if kind != expected: + raise Exception(f"Unexpected packet in file {str(path)} kind: {kind} expected: {expected}") + + +def assert_signature_type(path: Path, expected: str) -> None: + sig_type = packet_dump_field(packet=path, query="Type") + if sig_type != expected: + raise Exception(f"Unexpected packet type in file {str(path)} type: {sig_type} expected: {expected}") + + +def assert_signature_type_certification(path: Path) -> None: + sig_type = packet_dump_field(packet=path, query="Type") + if sig_type not in ["GenericCertification", "PersonaCertification", "CasualCertification", "PositiveCertification"]: + raise Exception(f"Unexpected packet certification type in file {str(path)} type: {sig_type}") + + +def assert_is_pgp_fingerprint(path: Path, _str: str) -> None: + if not is_pgp_fingerprint(_str): + raise Exception(f"Unexpected file name, not a pgp fingerprint: {str(path)}") + + +def assert_filename_matches_packet_issuer_fingerprint(path: Path, check: str) -> None: + fingerprint = packet_dump_field(packet=path, query="Unhashed area|Hashed area.Issuer Fingerprint") + if not fingerprint == check: + raise Exception(f"Unexpected packet fingerprint in file {str(path)}: {fingerprint}") + + +def assert_filename_matches_packet_fingerprint(path: Path, check: str) -> None: + fingerprint = packet_dump_field(packet=path, query="Fingerprint") + if not fingerprint == check: + raise Exception(f"Unexpected packet fingerprint in file {str(path)}: {fingerprint}") + + +def assert_has_suffix(path: Path, suffix: str) -> None: + if path.suffix != suffix: + raise Exception(f"Unexpected file suffix in {str(path)} expected: {suffix}") + + +def assert_is_file(path: Path) -> None: + if not path.is_file(): + raise Exception(f"Unexpected type, should be file: {str(path)}") + + +def assert_is_dir(path: Path) -> None: + if not path.is_dir(): + raise Exception(f"Unexpected type, should be directory: {str(path)}") + + +def raise_unexpected_file(path: Path) -> None: + raise Exception(f"Unexpected file in directory: {str(path)}") + + +def verify_integrity_key_revocations(path: Path) -> None: + assert_is_dir(path=path) + for sig in path.iterdir(): + assert_is_file(path=sig) + assert_is_pgp_fingerprint(path=sig, _str=sig.stem) + assert_has_suffix(path=sig, suffix=".asc") + + assert_packet_kind(path=sig, expected="Signature") + assert_signature_type(path=sig, expected="KeyRevocation") + + assert_filename_matches_packet_issuer_fingerprint(path=sig, check=sig.stem) + + debug(f"OK: {sig}") + + +def verify_integrity_direct_key_certifications(path: Path) -> None: + for issuer_dir in path.iterdir(): + assert_is_dir(path=issuer_dir) + assert_is_pgp_fingerprint(path=issuer_dir, _str=issuer_dir.name) + for certification in issuer_dir.iterdir(): + verify_integrity_direct_key_certification(path=certification) + + +def verify_integrity_direct_key_revocations(path: Path) -> None: + for issuer_dir in path.iterdir(): + assert_is_dir(path=issuer_dir) + assert_is_pgp_fingerprint(path=issuer_dir, _str=issuer_dir.name) + for certification in issuer_dir.iterdir(): + verify_integrity_direct_key_revocation(path=certification) + + +def verify_integrity_direct_key_certification(path: Path) -> None: + assert_is_file(path=path) + assert_has_suffix(path=path, suffix=".asc") + + assert_packet_kind(path=path, expected="Signature") + assert_signature_type(path=path, expected="DirectKey") + + assert_filename_matches_packet_issuer_fingerprint(path=path, check=path.parent.name) + + debug(f"OK: {path}") + + +def verify_integrity_direct_key_revocation(path: Path) -> None: + assert_is_file(path=path) + assert_has_suffix(path=path, suffix=".asc") + + assert_packet_kind(path=path, expected="Signature") + assert_signature_type(path=path, expected="CertificationRevocation") + + assert_filename_matches_packet_issuer_fingerprint(path=path, check=path.parent.name) + + debug(f"OK: {path}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6c5c724 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[tool.black] +line-length = 120 +exclude = ''' +/( + \.direnv| + |\.eggs + |\.git + |\.hg + |\.mypy_cache + |\.nox + |\.tox + |\.venv + |\.svn + |_build + |build + |dist +)/ +''' + +[tool.coverage.paths] +source = ["libkeyringctl"] + +[tool.coverage.report] +include = ["libkeyringctl/*", "keyringctl"] +precision = 2 +show_missing = true + +[tool.coverage.run] +branch = true +command_line = "-m pytest --junit-xml=build/junit-report.xml -vv tests/" +omit = ["tests/*", ".tox/*"] +relative_files = true + +[tool.coverage.xml] +output = "build/coverage.xml" + +[tool.isort] +profile = "black" +multi_line_output = 3 +force_single_line = true + +[tool.mypy] +ignore_missing_imports = true +follow_imports = "silent" +follow_imports_for_stubs = true +warn_unused_ignores = true +warn_no_return = true +warn_return_any = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true +no_implicit_optional = true +warn_unreachable = true +check_untyped_defs = true +disallow_any_generics = true +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..26db9af --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,344 @@ +from collections import defaultdict +from functools import wraps +from pathlib import Path +from random import choice +from random import randint +from shutil import copytree +from string import ascii_letters +from string import digits +from string import hexdigits +from string import punctuation +from subprocess import PIPE +from subprocess import Popen +from tempfile import NamedTemporaryFile +from tempfile import TemporaryDirectory +from typing import Any +from typing import Callable +from typing import Dict +from typing import Generator +from typing import List +from typing import Optional +from typing import Set + +from pytest import fixture + +from libkeyringctl.keyring import convert_certificate +from libkeyringctl.keyring import export +from libkeyringctl.keyring import get_fingerprints_from_keyring_files +from libkeyringctl.sequoia import certify +from libkeyringctl.sequoia import key_extract_certificate +from libkeyringctl.sequoia import key_generate +from libkeyringctl.sequoia import keyring_merge +from libkeyringctl.sequoia import packet_join +from libkeyringctl.types import Fingerprint +from libkeyringctl.types import Uid +from libkeyringctl.types import Username +from libkeyringctl.util import cwd +from libkeyringctl.util import simplify_uid +from libkeyringctl.util import system + +test_keys: Dict[Username, List[Path]] = defaultdict(list) +test_key_revocation: Dict[Username, List[Path]] = defaultdict(list) +test_certificates: Dict[Username, List[Path]] = defaultdict(list) +test_certificate_uids: Dict[Username, List[List[Uid]]] = defaultdict(list) +test_keyring_certificates: Dict[Username, List[Path]] = defaultdict(list) +test_main_fingerprints: Set[Fingerprint] = set() +test_all_fingerprints: Set[Fingerprint] = set() + + +@fixture(autouse=True) +def reset_storage() -> None: + test_keys.clear() + test_key_revocation.clear() + test_certificates.clear() + test_certificate_uids.clear() + test_keyring_certificates.clear() + test_main_fingerprints.clear() + test_all_fingerprints.clear() + + +def create_certificate( + username: Username, + uids: List[Uid], + keyring_type: str = "packager", + func: Optional[Callable[..., Any]] = None, +) -> Callable[..., Any]: + def decorator(decorated_func: Callable[..., None]) -> Callable[..., Any]: + @wraps(decorated_func) + def wrapper(working_dir: Path, *args: Any, **kwargs: Any) -> None: + key_directory = working_dir / "secret" / f"{username}" + key_directory.mkdir(parents=True, exist_ok=True) + + key_file: Path = key_directory / f"{username}.asc" + key_generate(uids=uids, outfile=key_file) + test_keys[username].append(key_file) + + certificate_directory = working_dir / "certificate" / f"{username}" + certificate_directory.mkdir(parents=True, exist_ok=True) + + keyring_root: Path = working_dir / "keyring" + keyring_root.mkdir(parents=True, exist_ok=True) + certificate_file: Path = certificate_directory / f"{username}.asc" + + key_extract_certificate(key=key_file, output=certificate_file) + test_certificates[username].append(certificate_file) + test_certificate_uids[username].append(uids) + + key_revocation_packet = key_file.parent / f"{key_file.name}.rev" + key_revocation_joined = key_file.parent / f"{key_file.name}.joined.rev" + key_revocation_cert = key_file.parent / f"{key_file.name}.cert.rev" + packet_join(packets=[certificate_file, key_revocation_packet], output=key_revocation_joined) + keyring_merge(certificates=[key_revocation_joined], output=key_revocation_cert) + test_key_revocation[username].append(key_revocation_cert) + + target_dir = keyring_root / keyring_type + + for fingerprint in get_fingerprints_from_keyring_files( + working_dir=working_dir, source=[certificate_file] + ).keys(): + test_all_fingerprints.add(fingerprint) + + decomposed_path: Path = convert_certificate( + working_dir=working_dir, + certificate=certificate_file, + keyring_dir=keyring_root / keyring_type, + fingerprint_filter=test_all_fingerprints, + ) + user_dir = decomposed_path.parent + (target_dir / user_dir.name).mkdir(parents=True, exist_ok=True) + copytree(src=user_dir, dst=(target_dir / user_dir.name), dirs_exist_ok=True) + test_keyring_certificates[username].append(target_dir / user_dir.name / decomposed_path.name) + + certificate_fingerprint: Fingerprint = Fingerprint(decomposed_path.name) + if "main" == keyring_type: + test_main_fingerprints.add(certificate_fingerprint) + test_all_fingerprints.add(certificate_fingerprint) + + decorated_func(working_dir=working_dir, *args, **kwargs) + + return wrapper + + if not func: + return decorator + return decorator(func) + + +def create_uid_certification( + issuer: Username, certified: Username, uid: Uid, func: Optional[Callable[[Any], None]] = None +) -> Callable[..., Any]: + def decorator(decorated_func: Callable[..., None]) -> Callable[..., Any]: + @wraps(decorated_func) + def wrapper(working_dir: Path, *args: Any, **kwargs: Any) -> None: + key: Path = test_keys[issuer][0] + certificate: Path = test_certificates[certified][0] + fingerprint: Fingerprint = Fingerprint(test_keyring_certificates[certified][0].name) + issuer_fingerprint: Fingerprint = Fingerprint(test_keyring_certificates[issuer][0].name) + simplified_uid = simplify_uid(Uid(uid)) + + output: Path = ( + working_dir + / "keyring" + / "packager" + / certified + / fingerprint + / "uid" + / simplified_uid + / "certification" + / f"{issuer_fingerprint}.asc" + ) + output.parent.mkdir(parents=True, exist_ok=True) + + certify(key, certificate, uid, output) + + decorated_func(working_dir=working_dir, *args, **kwargs) + + return wrapper + + if not func: + return decorator + return decorator(func) + + +def create_key_revocation( + username: Username, + keyring_type: str = "packager", + func: Optional[Callable[..., Any]] = None, +) -> Callable[..., Any]: + def decorator(decorated_func: Callable[..., None]) -> Callable[..., Any]: + @wraps(decorated_func) + def wrapper(working_dir: Path, *args: Any, **kwargs: Any) -> None: + revocation = test_key_revocation[username][0] + + keyring_root: Path = working_dir / "keyring" + keyring_root.mkdir(parents=True, exist_ok=True) + target_dir = keyring_root / keyring_type + + decomposed_path: Path = convert_certificate( + working_dir=working_dir, + certificate=revocation, + keyring_dir=keyring_root / keyring_type, + fingerprint_filter=test_all_fingerprints, + ) + user_dir = decomposed_path.parent + (target_dir / user_dir.name).mkdir(parents=True, exist_ok=True) + copytree(src=user_dir, dst=(target_dir / user_dir.name), dirs_exist_ok=True) + + decorated_func(working_dir=working_dir, *args, **kwargs) + + return wrapper + + if not func: + return decorator + return decorator(func) + + +def create_signature_revocation( + issuer: Username, certified: Username, uid: Uid, func: Optional[Callable[[Any], None]] = None +) -> Callable[..., Any]: + def decorator(decorated_func: Callable[..., None]) -> Callable[..., Any]: + @wraps(decorated_func) + def wrapper(working_dir: Path, *args: Any, **kwargs: Any) -> None: + issuer_key: Path = test_keys[issuer][0] + keyring_root: Path = working_dir / "keyring" + + keyring_certificate: Path = test_keyring_certificates[certified][0] + certified_fingerprint = keyring_certificate.name + + with NamedTemporaryFile(dir=str(working_dir), prefix=f"{certified}", suffix=".asc") as certificate: + certificate_path: Path = Path(certificate.name) + export( + working_dir=working_dir, + keyring_root=keyring_root, + sources=[keyring_certificate], + output=certificate_path, + ) + + with TemporaryDirectory(prefix="gnupg") as gnupg_home: + env = {"GNUPGHOME": gnupg_home} + + print( + system( + [ + "gpg", + "--no-auto-check-trustdb", + "--import", + f"{str(issuer_key)}", + f"{str(certificate_path)}", + ], + env=env, + ) + ) + + uid_confirmations = "" + for cert_uid in test_certificate_uids[certified][0]: + if uid == cert_uid: + uid_confirmations += "y\n" + else: + uid_confirmations += "n\n" + + commands = Popen(["echo", "-e", f"{uid_confirmations}y\n0\ny\n\ny\ny\nsave\n"], stdout=PIPE) + system( + [ + "gpg", + "--no-auto-check-trustdb", + "--command-fd", + "0", + "--expert", + "--yes", + "--batch", + "--edit-key", + f"{certified_fingerprint}", + "revsig", + "save", + ], + _stdin=commands.stdout, + env=env, + ) + + revoked_certificate = system(["gpg", "--armor", "--export", f"{certified_fingerprint}"], env=env) + certificate.truncate(0) + certificate.seek(0) + certificate.write(revoked_certificate.encode()) + certificate.flush() + + target_dir = keyring_root / "packager" + decomposed_path: Path = convert_certificate( + working_dir=working_dir, + certificate=certificate_path, + keyring_dir=target_dir, + fingerprint_filter=test_all_fingerprints, + ) + user_dir = decomposed_path.parent + (target_dir / user_dir.name).mkdir(parents=True, exist_ok=True) + copytree(src=user_dir, dst=(target_dir / user_dir.name), dirs_exist_ok=True) + + decorated_func(working_dir=working_dir, *args, **kwargs) + + return wrapper + + if not func: + return decorator + return decorator(func) + + +@fixture(scope="function") +def working_dir() -> Generator[Path, None, None]: + with TemporaryDirectory(prefix="arch-keyringctl-test-") as tempdir: + path: Path = Path(tempdir) + with cwd(path): + yield path + + +@fixture(scope="function") +def keyring_dir(working_dir: Path) -> Generator[Path, None, None]: + yield working_dir / "keyring" + + +def random_string(length: int, chars: str) -> str: + return "".join(choice(chars) for x in range(length)) + + +@fixture(scope="function", params=[16, 40], ids=["short_id", "long_id"]) +def valid_fingerprint(request: Any) -> Generator[str, None, None]: + yield random_string(length=request.param, chars="ABCDEF" + digits) + + +@fixture(scope="function", params=[16, 40], ids=["short_id", "long_id"]) +def valid_subkey_fingerprint(request: Any) -> Generator[str, None, None]: + yield random_string(length=request.param, chars="ABCDEF" + digits) + + +@fixture( + scope="function", + params=[ + ( + 16, + ascii_letters + hexdigits + punctuation, + ), + ( + 40, + ascii_letters + hexdigits + punctuation, + ), + ( + randint(0, 15), + "ABCDEF" + digits, + ), + ( + randint(17, 39), + "ABCDEF" + digits, + ), + ( + randint(41, 100), + "ABCDEF" + digits, + ), + ], + ids=[ + "short_id_wrong_chars", + "long_id_wrong_chars", + "right_chars_shorter_than_short_id", + "right_chars_shorter_than_long_id_longer_than_short_id", + "right_chars_longer_than_long_id", + ], +) +def invalid_fingerprint(request: Any) -> Generator[str, None, None]: + yield random_string(length=request.param[0], chars=request.param[1]) diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..13b204e --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import List +from typing import Optional +from unittest.mock import Mock +from unittest.mock import patch + +from pytest import mark + +from libkeyringctl import git + + +@mark.parametrize( + "git_path, base, paths", + [ + (None, None, None), + (Path("git_path"), None, None), + (Path("git_path"), "base", None), + (Path("git_path"), "base", [Path("foo"), Path("bar")]), + ], +) +@patch("libkeyringctl.git.system") +def test_git_changed_files( + system_mock: Mock, + git_path: Optional[Path], + base: Optional[str], + paths: Optional[List[Path]], +) -> None: + system_mock.return_value = "create some thing foo\n" "delete some thing bar\n" "some thing baz\n" + + assert git.git_changed_files(git_path=git_path, base=base, paths=paths) == ( + [Path("foo")], + [Path("bar")], + [Path("baz")], + ) + + name, args, kwargs = system_mock.mock_calls[0] + if git_path: + assert "-C" in args[0] + assert str(git_path) in args[0] + if base: + assert base in args[0] + if paths: + assert "--" in args[0] + for path in paths: + assert str(path) in args[0] diff --git a/tests/test_keyring.py b/tests/test_keyring.py new file mode 100644 index 0000000..3f3716d --- /dev/null +++ b/tests/test_keyring.py @@ -0,0 +1,804 @@ +from collections import defaultdict +from contextlib import nullcontext as does_not_raise +from copy import deepcopy +from datetime import datetime +from pathlib import Path +from random import choice +from string import digits +from typing import ContextManager +from typing import Dict +from typing import List +from typing import Optional +from typing import Set +from unittest.mock import Mock +from unittest.mock import patch + +from pytest import mark +from pytest import raises + +from libkeyringctl import keyring +from libkeyringctl.keyring import PACKET_FILENAME_DATETIME_FORMAT +from libkeyringctl.types import Fingerprint +from libkeyringctl.types import TrustFilter +from libkeyringctl.types import Uid +from libkeyringctl.types import Username + +from .conftest import create_certificate +from .conftest import create_key_revocation +from .conftest import create_signature_revocation +from .conftest import create_uid_certification +from .conftest import test_all_fingerprints +from .conftest import test_certificates +from .conftest import test_keyring_certificates +from .conftest import test_keys +from .conftest import test_main_fingerprints + + +def test_is_pgp_fingerprint( + valid_fingerprint: str, + invalid_fingerprint: str, +) -> None: + assert keyring.is_pgp_fingerprint(string=valid_fingerprint) is True + assert keyring.is_pgp_fingerprint(string=invalid_fingerprint) is False + + +@mark.parametrize( + "create_paths, create_paths_in_keyring_dir", + [ + (True, False), + (True, True), + (False, True), + (False, False), + ], +) +def test_transform_username_to_keyring_path( + create_paths: bool, + create_paths_in_keyring_dir: bool, + working_dir: Path, + keyring_dir: Path, +) -> None: + paths = [Path("test")] + input_paths = deepcopy(paths) + + for index, path in enumerate(paths): + path_in_working_dir = working_dir / path + if create_paths: + path_in_working_dir.mkdir() + + if create_paths_in_keyring_dir: + (keyring_dir / path).mkdir(parents=True) + + paths[index] = path_in_working_dir + + modified_paths = deepcopy(paths) + + keyring.transform_username_to_keyring_path(keyring_dir=keyring_dir, paths=paths) + + for index, path in enumerate(paths): + if create_paths or (not create_paths and not create_paths_in_keyring_dir): + assert path == modified_paths[index] + if not create_paths and create_paths_in_keyring_dir: + assert path == keyring_dir / input_paths[index] + + +@mark.parametrize( + "fingerprint_path, create_paths, create_paths_in_keyring_dir", + [ + (True, True, False), + (True, True, True), + (True, False, True), + (True, False, False), + (False, True, False), + (False, True, True), + (False, False, True), + (False, False, False), + ], +) +def test_transform_fingerprint_to_keyring_path( + fingerprint_path: bool, + create_paths: bool, + create_paths_in_keyring_dir: bool, + working_dir: Path, + keyring_dir: Path, + valid_fingerprint: str, +) -> None: + paths = [Path(valid_fingerprint) if fingerprint_path else Path("test")] + input_paths = deepcopy(paths) + + keyring_subdir = keyring_dir / "type" / "username" + + for index, path in enumerate(paths): + path_in_working_dir = working_dir / path + if create_paths: + path_in_working_dir.mkdir() + + if create_paths_in_keyring_dir: + (keyring_subdir / path).mkdir(parents=True) + + paths[index] = path_in_working_dir + + modified_paths = deepcopy(paths) + + keyring.transform_fingerprint_to_keyring_path(keyring_root=keyring_dir, paths=paths) + + for index, path in enumerate(paths): + if create_paths or (not fingerprint_path and not create_paths): + assert path == modified_paths[index] + if not create_paths and fingerprint_path and create_paths_in_keyring_dir: + assert path == keyring_subdir / input_paths[index] + + +@mark.parametrize( + "valid_current_packet_fingerprint, packet_type, issuer, expectation", + [ + (True, "KeyRevocation", "self", does_not_raise()), + (True, "DirectKey", "self", does_not_raise()), + (True, "GenericCertification", "self", does_not_raise()), + (True, "KeyRevocation", None, does_not_raise()), + (True, "CertificationRevocation", None, does_not_raise()), + (True, "CertificationRevocation", "self", does_not_raise()), + (True, "DirectKey", None, does_not_raise()), + (True, "GenericCertification", None, does_not_raise()), + (True, "KeyRevocation", "foo", raises(Exception)), + (True, "DirectKey", "foo", does_not_raise()), + (True, "GenericCertification", "foo", does_not_raise()), + (True, "foo", "foo", does_not_raise()), + (True, "foo", "self", raises(Exception)), + (False, "KeyRevocation", True, raises(Exception)), + (False, "DirectKey", True, raises(Exception)), + (False, "GenericCertification", True, raises(Exception)), + (False, "CertificationRevocation", True, raises(Exception)), + ], +) +@patch("libkeyringctl.keyring.get_fingerprint_from_partial") +@patch("libkeyringctl.keyring.packet_dump_field") +def test_convert_pubkey_signature_packet( + packet_dump_field_mock: Mock, + get_fingerprint_from_partial_mock: Mock, + valid_current_packet_fingerprint: bool, + packet_type: str, + issuer: Optional[str], + expectation: ContextManager[str], + working_dir: Path, + valid_fingerprint: Fingerprint, +) -> None: + packet = working_dir / "packet" + key_revocations: Dict[Fingerprint, Path] = {} + direct_revocations: Dict[Fingerprint, List[Path]] = defaultdict(list) + direct_sigs: Dict[Fingerprint, List[Path]] = defaultdict(list) + current_packet_fingerprint = None + + if valid_current_packet_fingerprint: + current_packet_fingerprint = valid_fingerprint + + packet_dump_field_mock.return_value = packet_type + if issuer == "self": + get_fingerprint_from_partial_mock.return_value = valid_fingerprint + else: + get_fingerprint_from_partial_mock.return_value = None if issuer is None else Fingerprint(issuer) + + with expectation: + keyring.convert_pubkey_signature_packet( + packet=packet, + certificate_fingerprint=valid_fingerprint, + fingerprint_filter=None, + current_packet_fingerprint=current_packet_fingerprint, + key_revocations=key_revocations, + direct_revocations=direct_revocations, + direct_sigs=direct_sigs, + ) + + if issuer is None or current_packet_fingerprint is None: + assert not direct_revocations and not direct_sigs and not key_revocations + else: + if packet_type == "KeyRevocation": + assert key_revocations[valid_fingerprint] == packet + elif packet_type in ["CertificationRevocation"]: + if issuer != "self": + assert not direct_revocations + else: + assert direct_revocations[valid_fingerprint if issuer == "self" else Fingerprint(issuer)] == [ + packet + ] + elif packet_type in ["DirectKey", "GenericCertification"]: + if issuer != "self": + assert not direct_sigs + else: + assert direct_sigs[valid_fingerprint if issuer == "self" else Fingerprint(issuer)] == [packet] + + +@mark.parametrize( + "valid_current_packet_uid, packet_type, provide_issuer, issuer_in_filter, expectation", + [ + (True, "CertificationRevocation", "self", True, does_not_raise()), + (True, "CertificationRevocation", "self", False, does_not_raise()), + (True, "SomeCertification", "self", True, does_not_raise()), + (True, "SomeCertification", "self", False, does_not_raise()), + (True, "CertificationRevocation", None, True, does_not_raise()), + (True, "CertificationRevocation", None, False, does_not_raise()), + (True, "SomeCertification", None, True, does_not_raise()), + (True, "SomeCertification", None, False, does_not_raise()), + (False, "CertificationRevocation", "self", True, raises(Exception)), + (False, "CertificationRevocation", "self", False, raises(Exception)), + (False, "SomeCertification", "self", True, raises(Exception)), + (False, "SomeCertification", "self", False, raises(Exception)), + (True, "foo", "self", True, raises(Exception)), + (True, "foo", "self", False, raises(Exception)), + ], +) +@patch("libkeyringctl.keyring.get_fingerprint_from_partial") +@patch("libkeyringctl.keyring.packet_dump_field") +def test_convert_uid_signature_packet( + packet_dump_field_mock: Mock, + get_fingerprint_from_partial_mock: Mock, + valid_current_packet_uid: bool, + packet_type: str, + provide_issuer: Optional[str], + issuer_in_filter: bool, + expectation: ContextManager[str], + working_dir: Path, + valid_fingerprint: Fingerprint, +) -> None: + packet = working_dir / "packet" + certifications: Dict[Uid, Dict[Fingerprint, List[Path]]] = defaultdict(lambda: defaultdict(list)) + revocations: Dict[Uid, Dict[Fingerprint, List[Path]]] = defaultdict(lambda: defaultdict(list)) + current_packet_uid = None + issuer = None + fingerprint_filter: Set[Fingerprint] = {Fingerprint("foo")} + + if valid_current_packet_uid: + current_packet_uid = Uid("Foobar McFooface ") + + packet_dump_field_mock.return_value = packet_type + if provide_issuer == "self": + issuer = valid_fingerprint + else: + if provide_issuer is not None: + issuer = Fingerprint(provide_issuer) + + get_fingerprint_from_partial_mock.return_value = issuer + + if issuer_in_filter and issuer is not None: + fingerprint_filter.add(issuer) + + with expectation: + keyring.convert_uid_signature_packet( + packet=packet, + current_packet_uid=current_packet_uid, + fingerprint_filter=fingerprint_filter, + certifications=certifications, + revocations=revocations, + ) + + if not valid_current_packet_uid or issuer is None: + assert not certifications and not revocations + else: + if packet_type == "CertificationRevocation" and valid_current_packet_uid and issuer_in_filter: + assert revocations[current_packet_uid][issuer] == [packet] # type: ignore + elif packet_type.endswith("Certification") and issuer_in_filter: + assert certifications[current_packet_uid][issuer] == [packet] # type: ignore + elif packet_type.endswith("Certification") and not issuer_in_filter: + assert not certifications + + +@mark.parametrize( + "valid_current_packet_fingerprint, packet_type, issuer, expectation", + [ + (True, "SubkeyBinding", "self", does_not_raise()), + (True, "SubkeyRevocation", "self", does_not_raise()), + (True, "SubkeyBinding", None, does_not_raise()), + (True, "SubkeyRevocation", None, does_not_raise()), + (True, "SubkeyBinding", "foo", raises(Exception)), + (True, "SubkeyRevocation", "foo", raises(Exception)), + (False, "SubkeyBinding", "self", raises(Exception)), + (False, "SubkeyRevocation", "self", raises(Exception)), + (True, "foo", "self", raises(Exception)), + ], +) +@patch("libkeyringctl.keyring.get_fingerprint_from_partial") +@patch("libkeyringctl.keyring.packet_dump_field") +def test_convert_subkey_signature_packet( + packet_dump_field_mock: Mock, + get_fingerprint_from_partial_mock: Mock, + valid_current_packet_fingerprint: bool, + packet_type: str, + issuer: Optional[str], + expectation: ContextManager[str], + working_dir: Path, + valid_fingerprint: Fingerprint, +) -> None: + packet = working_dir / "packet" + subkey_bindings: Dict[Fingerprint, List[Path]] = defaultdict(list) + subkey_revocations: Dict[Fingerprint, List[Path]] = defaultdict(list) + current_packet_fingerprint = None + + if valid_current_packet_fingerprint: + current_packet_fingerprint = valid_fingerprint + + packet_dump_field_mock.return_value = packet_type + if issuer == "self": + get_fingerprint_from_partial_mock.return_value = valid_fingerprint + else: + get_fingerprint_from_partial_mock.return_value = None if issuer is None else Fingerprint(issuer) + + with expectation: + keyring.convert_subkey_signature_packet( + packet=packet, + certificate_fingerprint=valid_fingerprint, + current_packet_fingerprint=current_packet_fingerprint, + fingerprint_filter=None, + subkey_bindings=subkey_bindings, + subkey_revocations=subkey_revocations, + ) + + if issuer is None or not valid_current_packet_fingerprint: + assert not subkey_bindings and not subkey_revocations + else: + if packet_type == "SubkeyBinding" and issuer == "self": + assert subkey_bindings[valid_fingerprint] == [packet] + elif packet_type == "SubkeyRevocation" and issuer == "self": + assert subkey_revocations[valid_fingerprint] == [packet] + + +@mark.parametrize( + "valid_certificate_fingerprint, current_packet_mode, expectation", + [ + (True, "pubkey", does_not_raise()), + (True, "uid", does_not_raise()), + (True, "subkey", does_not_raise()), + (True, "uattr", does_not_raise()), + (False, "pubkey", raises(Exception)), + (False, "uid", raises(Exception)), + (False, "subkey", raises(Exception)), + (False, "uattr", raises(Exception)), + (True, "foo", raises(Exception)), + ], +) +@patch("libkeyringctl.keyring.convert_pubkey_signature_packet") +@patch("libkeyringctl.keyring.convert_uid_signature_packet") +@patch("libkeyringctl.keyring.convert_subkey_signature_packet") +def test_convert_signature_packet( + convert_subkey_signature_packet_mock: Mock, + convert_uid_signature_packet_mock: Mock, + convert_pubkey_signature_packet_mock: Mock, + valid_certificate_fingerprint: bool, + current_packet_mode: str, + expectation: ContextManager[str], + valid_fingerprint: Fingerprint, +) -> None: + certificate_fingerprint = None + key_revocations: Dict[Fingerprint, Path] = {} + direct_revocations: Dict[Fingerprint, List[Path]] = defaultdict(list) + direct_sigs: Dict[Fingerprint, List[Path]] = defaultdict(list) + certifications: Dict[Uid, Dict[Fingerprint, List[Path]]] = defaultdict(lambda: defaultdict(list)) + revocations: Dict[Uid, Dict[Fingerprint, List[Path]]] = defaultdict(lambda: defaultdict(list)) + subkey_bindings: Dict[Fingerprint, List[Path]] = defaultdict(list) + subkey_revocations: Dict[Fingerprint, List[Path]] = defaultdict(list) + + if valid_certificate_fingerprint: + certificate_fingerprint = valid_fingerprint + + with expectation: + keyring.convert_signature_packet( + packet=Path("foo"), + current_packet_mode=current_packet_mode, + certificate_fingerprint=certificate_fingerprint, + fingerprint_filter=None, + key_revocations=key_revocations, + current_packet_fingerprint=None, + current_packet_uid=None, + direct_revocations=direct_revocations, + direct_sigs=direct_sigs, + certifications=certifications, + revocations=revocations, + subkey_bindings=subkey_bindings, + subkey_revocations=subkey_revocations, + ) + + if current_packet_mode == "pubkey": + convert_pubkey_signature_packet_mock.assert_called_once() + elif current_packet_mode == "uid": + convert_uid_signature_packet_mock.assert_called_once() + elif current_packet_mode == "subkey": + convert_subkey_signature_packet_mock.assert_called_once() + + +@mark.parametrize( + "packet, packet_split, packet_dump_field, name_override, expectation", + [ + ( + Path("foo.asc"), + [ + Path("--PublicKey"), + Path("--Signature"), + Path("--UserID"), + Path("--UserAttribute"), + Path("--PublicSubkey"), + Path("--Signature"), + ], + [ + "".join(choice("ABCDEF" + digits) for _ in range(40)), + "foo ", + "".join(choice("ABCDEF" + digits) for _ in range(40)), + ], + "bar", + does_not_raise(), + ), + ( + Path("foo.asc"), + [ + Path("--PublicKey"), + Path("--Signature"), + Path("--UserID"), + Path("--UserID"), + ], + [ + "".join(choice("ABCDEF" + digits) for _ in range(40)), + "foo ", + "foo ", + ], + "bar", + raises(Exception), + ), + ( + Path("foo.asc"), + [ + Path("--SecretKey"), + ], + [], + None, + raises(Exception), + ), + ( + Path("foo.asc"), + [ + Path("foo"), + ], + [], + None, + raises(Exception), + ), + ( + Path("foo.asc"), + [ + Path("--PublicKey"), + ], + [ + None, + ], + "bar", + raises(Exception), + ), + ], +) +@patch("libkeyringctl.keyring.persist_key_material") +@patch("libkeyringctl.keyring.packet_split") +@patch("libkeyringctl.keyring.convert_signature_packet") +@patch("libkeyringctl.keyring.packet_dump_field") +@patch("libkeyringctl.keyring.derive_username_from_fingerprint") +def test_convert_certificate( + derive_username_from_fingerprint_mock: Mock, + packet_dump_field_mock: Mock, + convert_signature_packet_mock: Mock, + packet_split_mock: Mock, + persist_key_material_mock: Mock, + packet: Path, + packet_split: List[Path], + packet_dump_field: List[str], + name_override: Optional[Username], + expectation: ContextManager[str], + working_dir: Path, + keyring_dir: Path, +) -> None: + packet_split_mock.return_value = packet_split + packet_dump_field_mock.side_effect = packet_dump_field + + with expectation: + keyring.convert_certificate( + working_dir=working_dir, + certificate=packet, + keyring_dir=keyring_dir, + name_override=name_override, + fingerprint_filter=None, + ) + + +@patch("libkeyringctl.keyring.latest_certification") +@patch("libkeyringctl.keyring.packet_join") +def test_persist_subkey_revocations( + packet_join_mock: Mock, + latest_certification_mock: Mock, + working_dir: Path, + keyring_dir: Path, + valid_fingerprint: Fingerprint, +) -> None: + revocation_packet = working_dir / "latest_revocation.asc" + latest_certification_mock.return_value = revocation_packet + subkey_revocations: Dict[Fingerprint, List[Path]] = { + valid_fingerprint: [revocation_packet, working_dir / "earlier_revocation.asc"] + } + keyring.persist_subkey_revocations( + key_dir=keyring_dir, + subkey_revocations=subkey_revocations, + issuer=valid_fingerprint, + ) + packet_join_mock.assert_called_once_with( + packets=[revocation_packet], + output=keyring_dir / "subkey" / valid_fingerprint / "revocation" / f"{valid_fingerprint}.asc", + force=True, + ) + + +@patch("libkeyringctl.keyring.packet_signature_creation_time") +@patch("libkeyringctl.keyring.packet_join") +def test_persist_directkey_revocations( + packet_join_mock: Mock, + packet_signature_creation_time_mock: Mock, + working_dir: Path, + keyring_dir: Path, + valid_fingerprint: Fingerprint, +) -> None: + revocation_packet = working_dir / "latest_revocation.asc" + directkey_revocations: Dict[Fingerprint, List[Path]] = {valid_fingerprint: [revocation_packet]} + + dt = datetime(2000, 1, 12, 11, 22, 33) + packet_signature_creation_time_mock.return_value = dt + keyring.persist_direct_key_revocations( + key_dir=keyring_dir, + direct_key_revocations=directkey_revocations, + ) + packet_join_mock.assert_called_once_with( + packets=[revocation_packet], + output=keyring_dir + / "directkey" + / "revocation" + / valid_fingerprint + / f"{dt.strftime(PACKET_FILENAME_DATETIME_FORMAT)}.asc", + force=True, + ) + + +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +def test_convert(working_dir: Path, keyring_dir: Path) -> None: + keyring.convert( + working_dir=working_dir, + keyring_root=keyring_dir, + sources=test_certificates[Username("foobar")], + target_dir=keyring_dir / "packager", + ) + + with raises(Exception): + keyring.convert( + working_dir=working_dir, + keyring_root=keyring_dir, + sources=test_keys[Username("foobar")], + target_dir=keyring_dir / "packager", + ) + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("main"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_signature_revocation(issuer=Username("main"), certified=Username("foobar"), uid=Uid("foobar ")) +def test_clean_keyring(working_dir: Path, keyring_dir: Path) -> None: + # first pass clean up certification + keyring.clean_keyring(keyring=keyring_dir) + # second pass skipping clean up because lack of certification + keyring.clean_keyring(keyring=keyring_dir) + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("other_main"), uids=[Uid("other main ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +def test_export_ownertrust(working_dir: Path, keyring_dir: Path) -> None: + output = working_dir / "build" + + keyring.export_ownertrust( + certs=[keyring_dir / "main"], + keyring_root=keyring_dir, + output=output, + ) + + with open(file=output, mode="r") as output_file: + for line in output_file.readlines(): + assert line.split(":")[0] in test_main_fingerprints + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_key_revocation(username=Username("foobar")) +def test_export_revoked(working_dir: Path, keyring_dir: Path) -> None: + output = working_dir / "build" + + keyring.export_revoked( + certs=[keyring_dir / "packager"], + keyring_root=keyring_dir, + main_keys=test_main_fingerprints, + output=output, + ) + + revoked_fingerprints = test_all_fingerprints - test_main_fingerprints + with open(file=output, mode="r") as output_file: + for line in output_file.readlines(): + assert line.strip() in revoked_fingerprints + + +@mark.parametrize("path_exists", [(True), (False)]) +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("main"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_key_revocation(username=Username("foobar")) +def test_get_packets_from_path(working_dir: Path, keyring_dir: Path, path_exists: bool) -> None: + if not path_exists: + assert keyring.get_packets_from_path(path=working_dir / "nope") == [] + else: + for username, paths in test_keyring_certificates.items(): + for path in paths: + keyring.get_packets_from_path(path=path) + + +@mark.parametrize("path_exists", [(True), (False)]) +@patch("libkeyringctl.keyring.get_packets_from_path") +def test_get_packets_from_listing(get_packets_from_path_mock: Mock, working_dir: Path, path_exists: bool) -> None: + path = working_dir / "path" + if not path_exists: + assert keyring.get_packets_from_listing(path=path) == [] + else: + get_packets_from_path_mock.return_value = [] + sub_path = path / "sub" + sub_path.mkdir(parents=True) + assert keyring.get_packets_from_listing(path=path) == [] + get_packets_from_path_mock.assert_called_once_with(sub_path) + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("main"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_key_revocation(username=Username("foobar")) +def test_export(working_dir: Path, keyring_dir: Path) -> None: + output_file = working_dir / "output" + + empty_dir = working_dir / "empty" + empty_dir.mkdir() + assert not keyring.export(working_dir=working_dir, keyring_root=empty_dir, sources=None, output=output_file) + assert not output_file.exists() + + keyring.export(working_dir=working_dir, keyring_root=keyring_dir, sources=None, output=output_file) + assert output_file.exists() + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("main"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_key_revocation(username=Username("foobar")) +def test_build(working_dir: Path, keyring_dir: Path) -> None: + output_dir = working_dir / "output" + + with raises(FileNotFoundError): + empty_dir = working_dir / "empty" + empty_dir.mkdir() + keyring.build(working_dir=working_dir, keyring_root=empty_dir, target_dir=output_dir) + + keyring.build(working_dir=working_dir, keyring_root=keyring_dir, target_dir=output_dir) + assert ( + (output_dir / "archlinux.gpg").exists() + and (output_dir / "archlinux-trusted").exists() + and (output_dir / "archlinux-revoked").exists() + ) + + +@mark.parametrize( + "create_dir, duplicate_fingerprints, expectation", + [ + (True, False, does_not_raise()), + (True, True, raises(Exception)), + (False, False, does_not_raise()), + (False, True, does_not_raise()), + ], +) +def test_derive_username_from_fingerprint( + create_dir: bool, + duplicate_fingerprints: bool, + expectation: ContextManager[str], + keyring_dir: Path, + valid_fingerprint: str, +) -> None: + username = "username" + other_username = "other_user" + + typed_keyring_dir = keyring_dir / "type" + + if create_dir: + (typed_keyring_dir / username / valid_fingerprint).mkdir(parents=True) + if duplicate_fingerprints: + (typed_keyring_dir / other_username / valid_fingerprint).mkdir(parents=True) + + with expectation: + returned_username = keyring.derive_username_from_fingerprint( + keyring_dir=typed_keyring_dir, + certificate_fingerprint=Fingerprint(valid_fingerprint), + ) + if create_dir and not duplicate_fingerprints: + assert returned_username == username + else: + assert returned_username is None + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +def test_list_keyring(working_dir: Path, keyring_dir: Path) -> None: + packager_fingerprints = test_all_fingerprints - test_main_fingerprints + + with patch("builtins.print") as print_mock: + keyring.list_keyring(keyring_root=keyring_dir, sources=None, main_keys=False) + print_args = [mock_call[1][0] for mock_call in print_mock.mock_calls] + for fingerprint in packager_fingerprints: + assert any([fingerprint in print_arg for print_arg in print_args]) + + with patch("builtins.print") as print_mock: + keyring.list_keyring(keyring_root=keyring_dir, sources=None, main_keys=True) + print_args = [mock_call[1][0] for mock_call in print_mock.mock_calls] + for fingerprint in test_main_fingerprints: + assert any([fingerprint in print_arg for print_arg in print_args]) + + for name, paths in test_keyring_certificates.items(): + if all(["main" in str(path) for path in paths]): + for path in paths: + with patch("builtins.print") as print_mock: + keyring.list_keyring(keyring_root=keyring_dir, sources=[path], main_keys=True) + print_args = [mock_call[1][0] for mock_call in print_mock.mock_calls] + assert name in print_args[0] and path.stem in print_args[0] + elif all(["packager" in str(path) for path in paths]): + for path in paths: + with patch("builtins.print") as print_mock: + keyring.list_keyring(keyring_root=keyring_dir, sources=[path], main_keys=False) + print_args = [mock_call[1][0] for mock_call in print_mock.mock_calls] + assert name in print_args[0] and path.stem in print_args[0] + with patch("builtins.print") as print_mock: + keyring.list_keyring( + keyring_root=keyring_dir, sources=paths, main_keys=False, trust_filter=TrustFilter.revoked + ) + print_args = [mock_call[1][0] for mock_call in print_mock.mock_calls] + assert not print_args + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +def test_inspect_keyring(working_dir: Path, keyring_dir: Path) -> None: + inspect_string = keyring.inspect_keyring(working_dir=working_dir, keyring_root=keyring_dir, sources=None) + for fingerprint in test_all_fingerprints: + assert fingerprint in inspect_string + + for name, paths in test_keyring_certificates.items(): + if all(["main" in str(path) for path in paths]): + for path in paths: + inspect_string = keyring.inspect_keyring( + working_dir=working_dir, + keyring_root=keyring_dir, + sources=[path], + ) + assert path.stem in inspect_string + elif all(["packager" in str(path) for path in paths]): + for path in paths: + inspect_string = keyring.inspect_keyring( + working_dir=working_dir, + keyring_root=keyring_dir, + sources=[path], + ) + assert path.stem in inspect_string + + +def test_get_fingerprints_from_paths(keyring_dir: Path, valid_fingerprint: str, valid_subkey_fingerprint: str) -> None: + fingerprint_dir = keyring_dir / "type" / "username" / valid_fingerprint + fingerprint_dir.mkdir(parents=True) + (fingerprint_dir / (fingerprint_dir.name + ".asc")).touch() + + fingerprint_subkey_dir = fingerprint_dir / "subkey" / valid_subkey_fingerprint + fingerprint_subkey_dir.mkdir(parents=True) + fingerprint_subkey_asc = fingerprint_subkey_dir / (fingerprint_subkey_dir.name + ".asc") + fingerprint_subkey_asc.touch() + + assert keyring.get_fingerprints_from_paths(sources=[fingerprint_subkey_dir]) == { + Fingerprint(valid_subkey_fingerprint) + } + assert keyring.get_fingerprints_from_paths(sources=[fingerprint_dir]) == {Fingerprint(valid_fingerprint)} diff --git a/tests/test_sequoia.py b/tests/test_sequoia.py new file mode 100644 index 0000000..6213a91 --- /dev/null +++ b/tests/test_sequoia.py @@ -0,0 +1,368 @@ +from contextlib import nullcontext as does_not_raise +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import ContextManager +from typing import Dict +from typing import Optional +from unittest.mock import Mock +from unittest.mock import patch + +from pytest import mark +from pytest import raises + +from libkeyringctl import sequoia +from libkeyringctl.types import Fingerprint +from libkeyringctl.types import PacketKind +from libkeyringctl.types import Uid +from libkeyringctl.types import Username + + +@mark.parametrize( + "create_subdir, preserve_filename", + [ + (False, True), + (False, False), + (True, True), + (True, False), + ], +) +@patch("libkeyringctl.sequoia.system") +@patch("libkeyringctl.sequoia.mkdtemp") +def test_keyring_split(mkdtemp_mock: Mock, system_mock: Mock, create_subdir: bool, preserve_filename: bool) -> None: + with TemporaryDirectory() as tmp_dir_name: + tmp_dir = Path(tmp_dir_name) + + keyring_tmp_dir = tmp_dir / "keyring" + keyring_tmp_dir.mkdir() + mkdtemp_mock.return_value = keyring_tmp_dir.absolute() + + if create_subdir: + keyring_sub_dir = keyring_tmp_dir / "foo" + keyring_sub_dir.mkdir() + + returned = sequoia.keyring_split( + working_dir=tmp_dir, + keyring=Path("foo"), + preserve_filename=preserve_filename, + ) + + if create_subdir: + assert returned == [keyring_sub_dir] + else: + assert returned == [] + + +@mark.parametrize( + "output", + [ + None, + Path("output"), + ], +) +@patch("libkeyringctl.sequoia.system") +def test_keyring_merge(system_mock: Mock, output: Optional[Path]) -> None: + certificates = [Path("foo"), Path("bar")] + system_mock.return_value = "return" + + assert sequoia.keyring_merge(certificates=certificates, output=output) == "return" + + name, args, kwargs = system_mock.mock_calls[0] + for cert in certificates: + assert str(cert) in args[0] + if output: + assert "--output" in args[0] and str(output) in args[0] + + +@patch("libkeyringctl.sequoia.system") +@patch("libkeyringctl.sequoia.mkdtemp") +def test_packet_split(mkdtemp_mock: Mock, system_mock: Mock) -> None: + certificate = Path("certificate") + with TemporaryDirectory() as tmp_dir_name: + tmp_dir = Path(tmp_dir_name) + + keyring_tmp_dir = tmp_dir / "keyring" + keyring_tmp_dir.mkdir() + mkdtemp_mock.return_value = keyring_tmp_dir.absolute() + keyring_sub_dir = keyring_tmp_dir / "foo" + keyring_sub_dir.mkdir() + + assert sequoia.packet_split(working_dir=tmp_dir, certificate=certificate) == [keyring_sub_dir] + name, args, kwargs = system_mock.mock_calls[0] + assert str(certificate) == args[0][-1] + + +@mark.parametrize("output, force", [(None, True), (None, False), (Path("output"), True), (Path("output"), False)]) +@patch("libkeyringctl.sequoia.system") +def test_packet_join(system_mock: Mock, output: Optional[Path], force: bool) -> None: + packets = [Path("packet1"), Path("packet2")] + system_return = "return" + system_mock.return_value = system_return + + assert sequoia.packet_join(packets, output=output, force=force) == system_return + + name, args, kwargs = system_mock.mock_calls[0] + for packet in packets: + assert str(packet) in args[0] + if force: + assert "--force" == args[0][1] + if output: + assert "--output" in args[0] and str(output) in args[0] + + +@mark.parametrize( + "certifications_in_result, certifications, fingerprints", + [ + ("something: 0123456789123456789012345678901234567890\n", True, None), + ("something: 0123456789123456789012345678901234567890\n", False, None), + ( + "something: 0123456789123456789012345678901234567890\n", + True, + {Fingerprint("0123456789123456789012345678901234567890"): Username("foo")}, + ), + ( + "something: 0123456789123456789012345678901234567890\n", + False, + {Fingerprint("0123456789123456789012345678901234567890"): Username("foo")}, + ), + ( + "something: 5678901234567890\n", + True, + {Fingerprint("0123456789123456789012345678901234567890"): Username("foo")}, + ), + ( + "something: 5678901234567890\n", + False, + {Fingerprint("0123456789123456789012345678901234567890"): Username("foo")}, + ), + ], +) +@patch("libkeyringctl.sequoia.system") +def test_inspect( + system_mock: Mock, + certifications_in_result: str, + certifications: bool, + fingerprints: Optional[Dict[Fingerprint, Username]], +) -> None: + packet = Path("packet") + result_header = "result\n" + + if certifications: + system_mock.return_value = result_header + "\n" + certifications_in_result + else: + system_mock.return_value = result_header + + returned = sequoia.inspect(packet=packet, certifications=certifications, fingerprints=fingerprints) + + if fingerprints and certifications: + for fingerprint, username in fingerprints.items(): + assert f"{fingerprint[24:]} {username}" in returned + assert result_header in returned + + +@patch("libkeyringctl.sequoia.system") +def test_packet_dump(system_mock: Mock) -> None: + system_mock.return_value = "return" + assert sequoia.packet_dump(packet=Path("packet")) == "return" + system_mock.called_once_with(["sq", "packet", "dump", "packet"]) + + +@mark.parametrize( + "packet_dump_return, query, result, expectation", + [ + ( + """ +Signature Packet + Version: 4 + Type: SubkeyBinding + Hash algo: SHA512 +""", + "Type", + "SubkeyBinding", + does_not_raise(), + ), + ( + """ +Signature Packet + Version: 4 + Type: SubkeyBinding + Hash algo: SHA512 + Hashed area: + Signature creation time: 2022-12-31 15:53:59 UTC + Issuer: BBBBBB + Unhashed area: + Issuer: 42424242 +""", + "Unhashed area.Issuer", + "42424242", + does_not_raise(), + ), + ( + """ +Signature Packet + Version: 4 + Type: SubkeyBinding + Hash algo: SHA512 + Hashed area: + Signature creation time: 2022-12-31 15:53:59 UTC + Unhashed area: + Issuer: 42424242 +""", + "Hashed area|Unhashed area.Issuer", + "42424242", + does_not_raise(), + ), + ( + """ +Signature Packet + Version: 4 + Type: SubkeyBinding + Hash algo: SHA1 + Hashed area: + Signature creation time: 2022-12-31 +""", + "*.Signature creation time", + "2022-12-31", + does_not_raise(), + ), + ( + """ +Signature Packet + a: + b: + x: foo + b: + b: + c: bar +""", + "*.b.c", + "bar", + does_not_raise(), + ), + ( + """ +Signature Packet + a: + b: + x: + y: + z: foo + b: + b: + x: + y: + z: foo + w: + w: foo + k: + i: + c: bar +""", + "*.b.*.*.c", + "bar", + does_not_raise(), + ), + ( + """ +Signature Packet + a: + c: + b: foo + a: + b: bar +""", + "a.b", + "bar", + does_not_raise(), + ), + ( + """ +Signature Packet + Version: 4 + Type: SubkeyBinding + Hash algo: SHA512 + Hashed area: + Signature creation time: 2022-12-31 15:53:59 UTC + Unhashed area: + Issuer: 42424242 + Issuer: BBBBBBBB +""", + "Hashed area.Issuer", + None, + raises(Exception), + ), + ], +) +@patch("libkeyringctl.sequoia.packet_dump") +def test_packet_dump_field( + packet_dump_mock: Mock, + packet_dump_return: str, + query: str, + result: str, + expectation: ContextManager[str], +) -> None: + packet_dump_mock.return_value = packet_dump_return + + with expectation: + assert sequoia.packet_dump_field(packet=Path("packet"), query=query) == result + + +@patch("libkeyringctl.sequoia.packet_dump_field") +def test_packet_signature_creation_time(packet_dump_field_mock: Mock) -> None: + creation_time = "2021-10-31 00:48:09 UTC" + packet_dump_field_mock.return_value = creation_time + assert sequoia.packet_signature_creation_time(packet=Path("packet")) == datetime.strptime( + creation_time, "%Y-%m-%d %H:%M:%S %Z" + ) + + +@patch("libkeyringctl.sequoia.packet_dump") +def test_packet_kinds(packet_dump_mock: Mock) -> None: + lines = [ + "Type1 something", + " foo", + "Type2", + "WARNING", + "Type3 other", + " bar", + ] + path = Path("foo") + packet_dump_mock.return_value = "\n".join(lines) + + assert sequoia.packet_kinds(packet=path) == [PacketKind("Type1"), PacketKind("Type2"), PacketKind("Type3")] + + +@patch("libkeyringctl.sequoia.packet_signature_creation_time") +def test_latest_certification(packet_signature_creation_time_mock: Mock) -> None: + now = datetime.now(tz=timezone.utc) + later = now + timedelta(days=1) + early_cert = Path("cert1") + later_cert = Path("cert2") + + packet_signature_creation_time_mock.side_effect = [now, later] + assert sequoia.latest_certification(certifications=[early_cert, later_cert]) == later_cert + + packet_signature_creation_time_mock.side_effect = [later, now] + assert sequoia.latest_certification(certifications=[later_cert, early_cert]) == later_cert + + +@mark.parametrize("output", [(None), (Path("output"))]) +@patch("libkeyringctl.sequoia.system") +def test_key_extract_certificate(system_mock: Mock, output: Optional[Path]) -> None: + system_mock.return_value = "return" + assert sequoia.key_extract_certificate(key=Path("key"), output=output) == "return" + name, args, kwargs = system_mock.mock_calls[0] + if output: + assert str(output) == args[0][-1] + + +@mark.parametrize("output", [(None), (Path("output"))]) +@patch("libkeyringctl.sequoia.system") +def test_certify(system_mock: Mock, output: Optional[Path]) -> None: + system_mock.return_value = "return" + assert sequoia.certify(key=Path("key"), certificate=Path("cert"), uid=Uid("uid"), output=output) == "return" + name, args, kwargs = system_mock.mock_calls[0] + if output: + assert str(output) == args[0][-1] diff --git a/tests/test_trust.py b/tests/test_trust.py new file mode 100644 index 0000000..014f6a4 --- /dev/null +++ b/tests/test_trust.py @@ -0,0 +1,381 @@ +from pathlib import Path +from typing import List +from unittest.mock import Mock +from unittest.mock import patch + +from pytest import mark +from pytest import raises + +from libkeyringctl.trust import certificate_trust +from libkeyringctl.trust import certificate_trust_from_paths +from libkeyringctl.trust import filter_by_trust +from libkeyringctl.trust import format_trust_label +from libkeyringctl.trust import trust_color +from libkeyringctl.trust import trust_icon +from libkeyringctl.types import Color +from libkeyringctl.types import Fingerprint +from libkeyringctl.types import Trust +from libkeyringctl.types import TrustFilter +from libkeyringctl.types import Uid +from libkeyringctl.types import Username + +from .conftest import create_certificate +from .conftest import create_key_revocation +from .conftest import create_signature_revocation +from .conftest import create_uid_certification +from .conftest import test_all_fingerprints +from .conftest import test_keyring_certificates +from .conftest import test_main_fingerprints + + +@mark.parametrize( + "sources", + [ + ([Path("foobar")]), + ([Path("foobar"), Path("quxdoo")]), + ], +) +@patch("libkeyringctl.trust.certificate_trust") +def test_certificate_trust_from_paths( + certificate_trust_mock: Mock, + sources: List[Path], + working_dir: Path, +) -> None: + certificate_trust_mock.return_value = Trust.full + for source in sources: + source.mkdir(parents=True, exist_ok=True) + cert = source / "foo.asc" + cert.touch() + + trusts = certificate_trust_from_paths( + sources=sources, main_keys=test_main_fingerprints, all_fingerprints=test_all_fingerprints + ) + for i, source in enumerate(sources): + name, args, kwargs = certificate_trust_mock.mock_calls[i] + assert kwargs["certificate"] == source + assert kwargs["main_keys"] == test_main_fingerprints + assert kwargs["all_fingerprints"] == test_all_fingerprints + fingerprint = Fingerprint(source.name) + assert Trust.full == trusts[fingerprint] + assert len(trusts) == len(sources) + + +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")], keyring_type="main") +def test_certificate_trust_main_key_has_full_trust(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.full == trust + + +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")], keyring_type="main") +@create_key_revocation(username=Username("foobar"), keyring_type="main") +def test_certificate_trust_main_key_revoked(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.revoked == trust + + +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")], keyring_type="main") +@create_key_revocation(username=Username("foobar"), keyring_type="main") +def test_certificate_trust_main_key_revoked_unknown_fingerprint_lookup(working_dir: Path, keyring_dir: Path) -> None: + fingerprint = Fingerprint(test_keyring_certificates[Username("foobar")][0].name) + revocation = list((keyring_dir / "main" / "foobar" / fingerprint / "revocation").iterdir())[0] + revocation.rename(revocation.parent / "12341234.asc") + with raises(Exception): + certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + {Fingerprint("12341234")}, + ) + + +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")], keyring_type="main") +@create_key_revocation(username=Username("foobar"), keyring_type="main") +def test_certificate_trust_main_key_revoked_unknown_self_revocation(working_dir: Path, keyring_dir: Path) -> None: + fingerprint = Fingerprint(test_keyring_certificates[Username("foobar")][0].name) + revocation = list((keyring_dir / "main" / "foobar" / fingerprint / "revocation").iterdir())[0] + revocation.rename(revocation.parent / "12341234.asc") + with raises(Exception): + certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + set(), + ) + + +@create_certificate(username=Username("main"), uids=[Uid("main ")]) +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +def test_certificate_trust_no_signature_is_unknown(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.unknown == trust + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("main"), certified=Username("foobar"), uid=Uid("foobar ")) +def test_certificate_trust_one_signature_is_marginal(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.marginal == trust + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("not_main"), uids=[Uid("main ")]) +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("not_main"), certified=Username("foobar"), uid=Uid("foobar ")) +def test_certificate_trust_one_none_main_signature_gives_no_trust(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.unknown == trust + + +@create_certificate(username=Username("main1"), uids=[Uid("main1 ")], keyring_type="main") +@create_certificate(username=Username("main2"), uids=[Uid("main2 ")], keyring_type="main") +@create_certificate(username=Username("main3"), uids=[Uid("main3 ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("main1"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("main2"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("main3"), certified=Username("foobar"), uid=Uid("foobar ")) +def test_certificate_trust_three_main_signature_gives_full_trust(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.full == trust + + +@create_certificate(username=Username("main1"), uids=[Uid("main1 ")], keyring_type="main") +@create_certificate(username=Username("main2"), uids=[Uid("main2 ")], keyring_type="main") +@create_certificate(username=Username("main3"), uids=[Uid("main3 ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("main1"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("main2"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("main3"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_key_revocation(username=Username("main3"), keyring_type="main") +def test_certificate_trust_three_main_signature_one_revoked(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.marginal == trust + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_key_revocation(username=Username("foobar")) +def test_certificate_trust_revoked_key(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.revoked == trust + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("main"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_signature_revocation(issuer=Username("main"), certified=Username("foobar"), uid=Uid("foobar ")) +def test_certificate_trust_one_signature_revoked(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.revoked == trust + + +@create_certificate(username=Username("main1"), uids=[Uid("main1 ")], keyring_type="main") +@create_certificate(username=Username("main2"), uids=[Uid("main2 ")], keyring_type="main") +@create_certificate(username=Username("main3"), uids=[Uid("main3 ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("main1"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("main2"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("main3"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_signature_revocation(issuer=Username("main3"), certified=Username("foobar"), uid=Uid("foobar ")) +def test_certificate_trust_revoked_if_below_full(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.revoked == trust + + +@create_certificate(username=Username("main1"), uids=[Uid("main1 ")], keyring_type="main") +@create_certificate(username=Username("main2"), uids=[Uid("main2 ")], keyring_type="main") +@create_certificate(username=Username("main3"), uids=[Uid("main3 ")], keyring_type="main") +@create_certificate(username=Username("main4"), uids=[Uid("main4 ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("main1"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("main2"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("main3"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("main4"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_signature_revocation(issuer=Username("main4"), certified=Username("foobar"), uid=Uid("foobar ")) +def test_certificate_trust_full_remains_if_enough_sigs_present(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.full == trust + + +@create_certificate(username=Username("main1"), uids=[Uid("main1 ")], keyring_type="main") +@create_certificate(username=Username("main2"), uids=[Uid("main2 ")], keyring_type="main") +@create_certificate(username=Username("main3"), uids=[Uid("main3 ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar "), Uid("old ")]) +@create_uid_certification(issuer=Username("main1"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("main2"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_signature_revocation(issuer=Username("foobar"), certified=Username("foobar"), uid=Uid("old ")) +def test_certificate_trust_not_revoked_if_only_one_uid_is_self_revoked(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.marginal == trust + + +@create_certificate(username=Username("foobar"), uids=[Uid("foobar "), Uid("old ")]) +@create_signature_revocation(issuer=Username("foobar"), certified=Username("foobar"), uid=Uid("old ")) +def test_certificate_trust_unknown_if_only_contains_self_revoked(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.unknown == trust + + +@create_certificate(username=Username("main1"), uids=[Uid("main1 ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar "), Uid("old ")]) +@create_uid_certification(issuer=Username("main1"), certified=Username("foobar"), uid=Uid("foobar ")) +def test_certificate_trust_missing_signature_fingerprint_lookup(working_dir: Path, keyring_dir: Path) -> None: + with raises(Exception): + certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + set(), + ) + + +@create_certificate(username=Username("foobar"), uids=[Uid("old ")]) +@create_signature_revocation(issuer=Username("foobar"), certified=Username("foobar"), uid=Uid("old ")) +def test_certificate_trust_missing_revocation_fingerprint_lookup(working_dir: Path, keyring_dir: Path) -> None: + with raises(Exception): + certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + set(), + ) + + +@create_certificate(username=Username("main1"), uids=[Uid("main1 ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_certificate(username=Username("packager"), uids=[Uid("packager ")]) +@create_uid_certification(issuer=Username("main1"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("packager"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_signature_revocation(issuer=Username("packager"), certified=Username("foobar"), uid=Uid("foobar ")) +def test_certificate_trust_ignore_3rd_party_revocation(working_dir: Path, keyring_dir: Path) -> None: + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + test_all_fingerprints, + ) + assert Trust.marginal == trust + + +@mark.parametrize( + "trust, result", + [ + (Trust.revoked, Color.RED), + (Trust.full, Color.GREEN), + (Trust.marginal, Color.YELLOW), + (Trust.unknown, Color.YELLOW), + ], +) +def test_trust_color(trust: Trust, result: Color) -> None: + assert trust_color(trust) == result + + +@mark.parametrize( + "trust, result", + [ + (Trust.revoked, "✗"), + (Trust.full, "✓"), + (Trust.marginal, "~"), + (Trust.unknown, "~"), + (None, "?"), + ], +) +def test_trust_icon(trust: Trust, result: str) -> None: + assert trust_icon(trust) == result + + +@mark.parametrize( + "trust", + [ + Trust.revoked, + Trust.full, + Trust.marginal, + Trust.unknown, + ], +) +@patch("libkeyringctl.trust.trust_icon") +@patch("libkeyringctl.trust.trust_color") +def test_format_trust_label(trust_color_mock: Mock, trust_icon_mock: Mock, trust: Trust) -> None: + trust_icon_mock.return_value = "ICON" + trust_color_mock.return_value = Color.GREEN + assert f"{Color.GREEN.value}ICON {trust.name}{Color.RST.value}" == format_trust_label(trust) + + +@mark.parametrize( + "trust, trust_filter, result", + [ + (Trust.revoked, TrustFilter.unknown, False), + (Trust.full, TrustFilter.unknown, False), + (Trust.marginal, TrustFilter.unknown, False), + (Trust.unknown, TrustFilter.unknown, True), + (Trust.revoked, TrustFilter.marginal, False), + (Trust.full, TrustFilter.marginal, False), + (Trust.marginal, TrustFilter.marginal, True), + (Trust.unknown, TrustFilter.marginal, False), + (Trust.revoked, TrustFilter.full, False), + (Trust.full, TrustFilter.full, True), + (Trust.marginal, TrustFilter.full, False), + (Trust.unknown, TrustFilter.full, False), + (Trust.revoked, TrustFilter.revoked, True), + (Trust.full, TrustFilter.revoked, False), + (Trust.marginal, TrustFilter.revoked, False), + (Trust.unknown, TrustFilter.revoked, False), + (Trust.revoked, TrustFilter.unrevoked, False), + (Trust.full, TrustFilter.unrevoked, True), + (Trust.marginal, TrustFilter.unrevoked, True), + (Trust.unknown, TrustFilter.unrevoked, True), + (Trust.revoked, TrustFilter.all, True), + (Trust.full, TrustFilter.all, True), + (Trust.marginal, TrustFilter.all, True), + (Trust.unknown, TrustFilter.all, True), + ], +) +def test_filter_by_trust(trust: Trust, trust_filter: TrustFilter, result: bool) -> None: + assert filter_by_trust(trust=trust, trust_filter=trust_filter) == result diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..6688b81 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,198 @@ +from os import chdir +from os import getcwd +from pathlib import Path +from tempfile import NamedTemporaryFile +from tempfile import TemporaryDirectory +from typing import Dict +from typing import List +from unittest.mock import Mock +from unittest.mock import patch + +from pytest import mark +from pytest import raises + +from libkeyringctl import util +from libkeyringctl.types import Fingerprint +from libkeyringctl.types import Trust + + +def test_cwd() -> None: + with TemporaryDirectory() as tmp_dir_name: + tmp_dir = Path(tmp_dir_name) + test_dir = tmp_dir / "test" + test_dir.mkdir() + chdir(tmp_dir) + with util.cwd(new_dir=test_dir): + assert getcwd() == str(test_dir) + assert getcwd() == str(tmp_dir) + + +@mark.parametrize( + "input_list, output_list", + [ + ([Path("/foo"), Path("/bar/foo"), Path("/foo/20")], [Path("/foo/20"), Path("/foo"), Path("/bar/foo")]), + ], +) +def test_natural_sort_path(input_list: List[Path], output_list: List[Path]) -> None: + assert util.natural_sort_path(_list=input_list) == output_list + + +@mark.parametrize( + "raise_on_cmd, exit_on_error", + [ + (False, True), + (False, False), + (True, True), + (True, False), + ], +) +@patch("libkeyringctl.util.exit") +@patch("libkeyringctl.util.check_output") +def test_system(check_output_mock: Mock, exit_mock: Mock, raise_on_cmd: bool, exit_on_error: bool) -> None: + if raise_on_cmd: + check_output_mock.side_effect = util.CalledProcessError(returncode=1, cmd="foo", output=b"output") + + with raises(util.CalledProcessError): + util.system(["foo"], exit_on_error=exit_on_error) + if exit_on_error: + exit_mock.assert_called_once_with(1) + + else: + check_output_mock.return_value = b"output" + assert util.system(["foo"], exit_on_error=exit_on_error) == "output" + + +def test_absolute_path() -> None: + with TemporaryDirectory() as tmp_dir_name: + tmp_dir = Path(tmp_dir_name) + test_dir = tmp_dir / "test" + test_dir.mkdir() + chdir(tmp_dir) + assert util.absolute_path(path="test") == test_dir + + +def test_transform_fd_to_tmpfile() -> None: + with TemporaryDirectory() as tmp_dir_name: + tmp_dir = Path(tmp_dir_name) + with NamedTemporaryFile(dir=tmp_dir) as tmp_file: + tmp_file_fd = tmp_file.fileno() + util.transform_fd_to_tmpfile( + working_dir=tmp_dir, + sources=[Path("/foo"), Path(f"/proc/self/fd/{tmp_file_fd}")], + ) + + +def test_get_cert_paths() -> None: + with TemporaryDirectory() as tmp_dir_name: + tmp_dir = Path(tmp_dir_name) + + cert_dir1 = tmp_dir / "cert1" + cert_dir1.mkdir() + cert1 = cert_dir1 / "cert1.asc" + cert1.touch() + cert_dir2 = tmp_dir / "cert2" + cert_dir2.mkdir() + cert2 = cert_dir2 / "cert2.asc" + cert2.touch() + + assert util.get_cert_paths(paths=[tmp_dir]) == {cert_dir1, cert_dir2} + + +def test_get_parent_cert_paths() -> None: + with TemporaryDirectory() as tmp_dir_name: + tmp_dir = Path(tmp_dir_name) + + keyring_dir = tmp_dir / "keyring" + group_dir = keyring_dir / "parent" + user_dir = group_dir / "parent" + cert_dir1 = user_dir / "cert1" + cert_dir1.mkdir(parents=True) + cert1 = cert_dir1 / "cert1.asc" + cert1.touch() + cert_dir2 = cert_dir1 / "cert2" + cert_dir2.mkdir(parents=True) + cert2 = cert_dir2 / "cert2.asc" + cert2.touch() + + assert util.get_parent_cert_paths(paths=[cert1, cert2]) == {cert_dir1} + + +@mark.parametrize( + "fingerprints, fingerprint, result", + [ + ( + [Fingerprint("foo"), Fingerprint("bar")], + Fingerprint("foo"), + True, + ), + ( + [Fingerprint("foo"), Fingerprint("bar")], + Fingerprint("baz"), + False, + ), + ], +) +def test_contains_fingerprint(fingerprints: List[Fingerprint], fingerprint: Fingerprint, result: bool) -> None: + assert util.contains_fingerprint(fingerprints=fingerprints, fingerprint=fingerprint) is result + + +@mark.parametrize( + "fingerprints, fingerprint, result", + [ + ([Fingerprint("blahfoo"), Fingerprint("blahbar")], Fingerprint("foo"), Fingerprint("blahfoo")), + ([Fingerprint("blahfoo"), Fingerprint("blahbar")], Fingerprint("blahfoo"), Fingerprint("blahfoo")), + ( + [Fingerprint("bazfoo"), Fingerprint("bazbar")], + Fingerprint("baz"), + None, + ), + ], +) +def test_get_fingerprint_from_partial(fingerprints: List[Fingerprint], fingerprint: Fingerprint, result: bool) -> None: + assert util.get_fingerprint_from_partial(fingerprints=fingerprints, fingerprint=fingerprint) is result + + +@mark.parametrize( + "trusts, trust, result", + [ + ( + {Fingerprint("foo"): Trust.full, Fingerprint("bar"): Trust.marginal}, + Trust.full, + [Fingerprint("foo")], + ), + ( + {Fingerprint("foo"): Trust.full, Fingerprint("bar"): Trust.full}, + Trust.full, + [Fingerprint("foo"), Fingerprint("bar")], + ), + ( + {Fingerprint("foo"): Trust.full, Fingerprint("bar"): Trust.marginal}, + Trust.unknown, + [], + ), + ( + {}, + Trust.unknown, + [], + ), + ], +) +def test_filter_fingerprints_by_trust( + trusts: Dict[Fingerprint, Trust], trust: Trust, result: List[Fingerprint] +) -> None: + assert util.filter_fingerprints_by_trust(trusts=trusts, trust=trust) == result + + +@mark.parametrize( + "_str, result", + [ + ("foobar", "foobar"), + ("", ""), + ("bbàáâãğț aa", "bbaaaagt_aa"), + ("<>", ""), + ("!#$%^&*()_☃", "___________"), + ("_-.+@", "_-.+@"), + ], +) +def test_simplify_ascii(_str: str, result: str) -> None: + assert util.simplify_ascii(_str=_str) == result