├── .flake8 ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── README.rst ├── bin └── serles ├── config.ini.example ├── docs ├── Makefile ├── api.rst ├── conf.py ├── ejbca-configuration.rst ├── genindex.rst ├── index.rst └── installation.rst ├── examples-clients ├── README.md ├── acme_sh.sh ├── acme_tiny.sh ├── ansible.yaml ├── certbot.sh ├── exos-ansible-acme │ ├── acme_port80.py │ └── exosv4.yaml └── gen-csr.sh ├── serles ├── __init__.py ├── __main__.py ├── backends │ ├── __init__.py │ ├── base.py │ ├── dane.py │ └── ejbca.py ├── challenge.py ├── configloader.py ├── exceptions.py ├── flask_handlers.py ├── models.py ├── templates │ ├── home.html │ └── tlsa.html ├── utils.py ├── views.py └── wsgi.py ├── setup.py └── tests ├── Makefile ├── MockBackend.py ├── data_config.ini ├── data_example.test.csr.bin ├── data_nocn.csr ├── data_pemchain.txt ├── data_pkcs7.bin ├── data_privkey.pem ├── good.pemchain ├── integration.sh ├── test_appfactory.py ├── test_challengefuns.py ├── test_configparser.py ├── test_ejbcabackend.py ├── test_flaskfuns.py ├── test_helpers.py └── test_views.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore = E501 # line too long -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pyc 3 | config.ini 4 | *.sqlite 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.6 4 | before_install: 5 | - python --version 6 | - pip install -U pip 7 | - pip install -U pytest 8 | - pip install codecov 9 | install: 10 | - pip install ".[test]" . 11 | script: python -m coverage run -m pytest tests 12 | after_success: 13 | - python -m codecov 14 | deploy: 15 | provider: pypi 16 | user: __token__ 17 | password: $PYPI_TOKEN 18 | distributions: "sdist bdist_wheel" 19 | on: 20 | tags: true 21 | branch: master 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Handshake Tools DANE CA 2 | 3 | **Live:** https://acme.htools.work/ 4 | 5 | This is a fork of [dvtirol/serles-acme](https://github.com/dvtirol/serles-acme), modified to issue certificates for use with DANE (on [Handshake](https://handshake.org) domains). 6 | 7 | ## Features 8 | 9 | - Use with any ACME client ([certbot](https://certbot.eff.org/), etc.) 10 | - Generate certificates for Handshake domains 11 | - New CA keys are generated (and destroyed) while signing every certificate 12 | 13 | ### Customization options 14 | 15 | Requests are flexible and options are passed by appending keywords to the email address: 16 | 17 | - `+email` - opt into receiving emails with TLSA records after every cert issue. 18 | - `+nohip17` - opt out of HIP-17 (Stateless DANE) extensions 19 | - `+longttl` - request for long-lived certificates (1 year) - only applies if no HIP-17 20 | 21 | Options can be combined. Example: 22 | 23 | - `myvalidaddress+email+longttl+nohip17@gmail.com` - get emails and long-lived certs without HIP-17 extensions 24 | 25 | ## Usage 26 | 27 | Use an ACME client like you would for any regular website, along with a new arg `--server`: 28 | 29 | ```sh 30 | sudo certbot --nginx -d your_tld.or_sld --server https://acme.htools.work/directory --reuse-key 31 | ``` 32 | 33 | ## Documentation 34 | 35 | Check out the original project this is forked from: [dvtirol/serles-acme](https://github.com/dvtirol/serles-acme) 36 | 37 | ## Run Locally 38 | 39 | Clone the project: 40 | 41 | ```sh 42 | git clone https://github.com/htools-org/htools-dane-ca 43 | cd htools-dane-ca 44 | ``` 45 | 46 | Set up a virtual env with `venv` or `pyenv` and activate it. 47 | 48 | Then install dependencies: 49 | 50 | ```sh 51 | pip install serles-acme 52 | ``` 53 | 54 | Create a config file from the example: 55 | 56 | ```sh 57 | cp config.ini.example config.ini 58 | # and then fill in values as needed. 59 | ``` 60 | 61 | Finally, tart the server with: 62 | 63 | ```sh 64 | CONFIG=./config.ini python -m serles 65 | ``` 66 | 67 | Any client can connect to it now: 68 | 69 | ```sh 70 | # either edit values in this script, or run certbot as usual 71 | ./examples-clients/certbot.sh 72 | ``` 73 | 74 | ## Support 75 | 76 | For any support/help, feel free to join Handshake's [Telegram](https://t.me/hns_tech) or [Discord](https://discord.gg/AtqtxGckqX) groups and we'll do our best to find out what's wrong. 77 | 78 | If there's any problem with the code or have suggestions, [create a new issue](https://github.com/htools-org/htools-dane-ca/issues/new). 79 | 80 | ## License 81 | 82 | [GPL-3.0 License](https://choosealicense.com/licenses/gpl-3.0/) 83 | 84 | ## Credit 85 | 86 | Thanks to 87 | 88 | - @dvtirol for [dvtirol/serles-acme](https://github.com/dvtirol/serles-acme) this project is forked from (backend modularity was very useful!) 89 | - @brandondees for the idea of using CA this way 90 | - @buffrr for advice on certificates 91 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | docs/index.rst -------------------------------------------------------------------------------- /bin/serles: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | gunicorn -c /etc/serles/gunicorn_config.py 'serles:create_app()' 4 | -------------------------------------------------------------------------------- /config.ini.example: -------------------------------------------------------------------------------- 1 | [serles] 2 | # Hostname for Flask url generation (without scheme, with port) 3 | server_name = localhost 4 | 5 | # Database to use, given as a URI understood by SQLAlchemy. 6 | # Note: The database stores the Account id-to-key mapping. Some clients (e.g. 7 | # certbot) will after registering assume their public key is known to Serles 8 | # and only identify themselves through their account id. Hence, the database 9 | # should be persistent. All other data can be ephemeral, and is purged by 10 | # Serles regularly (/// is relative path, //// absolute. yes, really.). 11 | # If you don't care about account keys, and given that you are running only 1 12 | # process and 1 thread, you can use an in-memory database by setting it to 13 | # "sqlite:///:memory:". 14 | database = sqlite:////etc/serles/db.sqlite 15 | # Mariadb is also supported, but you will need to install pymysql from pypi. 16 | #database = mysql+pymysql://user:passwd@localhost/db_acmeproxy 17 | 18 | # [:] of the backend to load. if classname is not given, 19 | # defaults to Backend. supports relative imports. 20 | backend = serles.backends.dane:DaneBackend 21 | 22 | # if you only want to issue certificates for a few subnets, define them in CIDR 23 | # notation here (newline-seperated): 24 | allowedServerIpRanges = 25 | ::1/128 26 | 127.0.0.0/8 27 | 10.0.0.0/8 28 | excludeServerIpRanges = 29 | 127.0.0.2/32 30 | 31 | # if allowedServerIpRanges are set, whether to also verify that a correct PTR 32 | # record exists: 33 | verifyPTR = false 34 | 35 | # if the CSR does not set a Subject Name, fill it in with this template (you 36 | # can use the special variable {SAN} to access subjectAltName.dnsName values 37 | # and {MAIL} to access the requesting user's email address): 38 | subjectNameTemplate = {SAN[0]} 39 | 40 | # if set to true, ignore DN from CSR and always create it from the 41 | # subjectNameTemplate above: 42 | forceTemplateDN = true 43 | 44 | # if set, HTTP challenge isn't verified for ownership 45 | skip_challenge = false 46 | 47 | [sendgrid] 48 | api_key = api-key 49 | template_id = template-id 50 | asm_group_id = 0 # unsubscribe group id 51 | from_name = From Name 52 | from_email = from@email.co 53 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | Internal Documentation 2 | ====================== 3 | 4 | See also: :ref:`modindex` 5 | 6 | .. automodule:: serles 7 | :members: 8 | 9 | .. automodule:: serles.backends.ejbca 10 | :members: 11 | 12 | .. automodule:: serles.backends.base 13 | :members: 14 | 15 | .. automodule:: serles.models 16 | :members: 17 | 18 | .. automodule:: serles.challenge 19 | :members: 20 | 21 | .. automodule:: serles.configloader 22 | :members: 23 | 24 | .. automodule:: serles.exceptions 25 | :members: 26 | 27 | .. automodule:: serles.flask_handlers 28 | :members: 29 | 30 | .. automodule:: serles.utils 31 | :members: 32 | 33 | .. automodule:: serles.views 34 | :members: 35 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | 18 | sys.path.insert(0, os.path.abspath("..")) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "serles" 24 | copyright = "2020, Daten-Verarbeitung-Tirol GmbH" 25 | author = "Daten-Verarbeitung-Tirol GmbH" 26 | 27 | # The short X.Y version 28 | version = "" 29 | # The full version, including alpha/beta/rc tags 30 | release = "" 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | # 37 | # needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | "sphinx.ext.autodoc", 44 | "sphinx.ext.napoleon", 45 | "sphinx.ext.extlinks", 46 | ] 47 | 48 | # Add any paths that contain templates here, relative to this directory. 49 | templates_path = ["_templates"] 50 | 51 | # The suffix(es) of source filenames. 52 | # You can specify multiple suffix as a list of string: 53 | # 54 | # source_suffix = ['.rst', '.md'] 55 | source_suffix = ".rst" 56 | 57 | # The master toctree document. 58 | master_doc = "index" 59 | 60 | # The language for content autogenerated by Sphinx. Refer to documentation 61 | # for a list of supported languages. 62 | # 63 | # This is also used if you do content translation via gettext catalogs. 64 | # Usually you set "language" from the command line for these cases. 65 | language = None 66 | 67 | # List of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | # This pattern also affects html_static_path and html_extra_path. 70 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 71 | 72 | # The name of the Pygments (syntax highlighting) style to use. 73 | pygments_style = None 74 | 75 | 76 | # -- Options for HTML output ------------------------------------------------- 77 | 78 | # The theme to use for HTML and HTML Help pages. See the documentation for 79 | # a list of builtin themes. 80 | # 81 | html_theme = "alabaster" 82 | 83 | # Theme options are theme-specific and customize the look and feel of a theme 84 | # further. For a list of options available for each theme, see the 85 | # documentation. 86 | # 87 | # html_theme_options = {} 88 | 89 | # Add any paths that contain custom static files (such as style sheets) here, 90 | # relative to this directory. They are copied after the builtin static files, 91 | # so a file named "default.css" will overwrite the builtin "default.css". 92 | html_static_path = ["_static"] 93 | 94 | # Custom sidebar templates, must be a dictionary that maps document names 95 | # to template names. 96 | # 97 | # The default sidebars (for documents that don't match any pattern) are 98 | # defined by theme itself. Builtin themes are using these templates by 99 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 100 | # 'searchbox.html']``. 101 | # 102 | # html_sidebars = {} 103 | 104 | 105 | # -- Options for HTMLHelp output --------------------------------------------- 106 | 107 | # Output file base name for HTML help builder. 108 | htmlhelp_basename = "serles_doc" 109 | 110 | 111 | # -- Options for LaTeX output ------------------------------------------------ 112 | 113 | latex_elements = { 114 | # The paper size ('letterpaper' or 'a4paper'). 115 | # 116 | # 'papersize': 'letterpaper', 117 | # The font size ('10pt', '11pt' or '12pt'). 118 | # 119 | # 'pointsize': '10pt', 120 | # Additional stuff for the LaTeX preamble. 121 | # 122 | # 'preamble': '', 123 | # Latex figure (float) alignment 124 | # 125 | # 'figure_align': 'htbp', 126 | } 127 | 128 | # Grouping the document tree into LaTeX files. List of tuples 129 | # (source start file, target name, title, 130 | # author, documentclass [howto, manual, or own class]). 131 | latex_documents = [ 132 | ( 133 | master_doc, 134 | "serles.tex", 135 | "Serles ACME Server Documentation", 136 | "Daten-Verarbeitung-Tirol GmbH", 137 | "manual", 138 | ), 139 | ] 140 | 141 | 142 | # -- Options for manual page output ------------------------------------------ 143 | 144 | # One entry per manual page. List of tuples 145 | # (source start file, name, description, authors, manual section). 146 | man_pages = [(master_doc, "serles", "Serles ACME Server Documentation", [author], 1)] 147 | 148 | 149 | # -- Options for Texinfo output ---------------------------------------------- 150 | 151 | # Grouping the document tree into Texinfo files. List of tuples 152 | # (source start file, target name, title, author, 153 | # dir menu entry, description, category) 154 | texinfo_documents = [ 155 | ( 156 | master_doc, 157 | "serles", 158 | "Serles ACME Server Documentation", 159 | author, 160 | "serles", 161 | "Serles: An Extensible ACME Server", 162 | "Miscellaneous", 163 | ), 164 | ] 165 | 166 | 167 | # -- Options for Epub output ------------------------------------------------- 168 | 169 | # Bibliographic Dublin Core info. 170 | epub_title = project 171 | 172 | # The unique identifier of the text. This can be a ISBN number 173 | # or the project homepage. 174 | # 175 | # epub_identifier = '' 176 | 177 | # A unique identification for the text. 178 | # 179 | # epub_uid = '' 180 | 181 | # A list of files that should not be packed into the epub file. 182 | epub_exclude_files = ["search.html"] 183 | 184 | 185 | # -- Extension configuration ------------------------------------------------- 186 | 187 | extlinks = {"ejbca": ("https://localhost:9443/ejbca/%s", None)} 188 | -------------------------------------------------------------------------------- /docs/ejbca-configuration.rst: -------------------------------------------------------------------------------- 1 | .. |ejbca-host| replace:: localhost:9443 2 | 3 | .. _ejbca-configuration: 4 | 5 | EJBCA Dev Environment Quickstart 6 | ================================ 7 | 8 | This document describes the steps necessary to install and setup a minimal 9 | EJBCA instance for testing Serles. 10 | 11 | 1. Install EJBCA Community 12 | -------------------------- 13 | 14 | .. code-block:: shell 15 | 16 | docker pull primekey/ejbca-ce 17 | docker run -it -p 9980:8080 -p 9443:8443 -h ejbca-test -e TLS_SETUP_ENABLED="simple" primekey/ejbca-ce 18 | 19 | Exposes the Web UI on Port 9443 using a self-signed certificate (Note that the 20 | SOAP-API cannot be accessed over plain text). Port 8443 is the default port of 21 | Serles, so we'll use 9443 for EJBCA. 22 | 23 | 2. Configure EJBCA for use with Serles 24 | -------------------------------------- 25 | 26 | 1. Create a Certificate Authority 27 | 28 | :ejbca:`Certification Authorities ` 29 | 30 | suggested name: *ACMECA* 31 | 32 | 2. create a cert profile 33 | 34 | :ejbca:`Certificate Profiles ` 35 | 36 | suggested name: *ACMEServerProfile* 37 | 38 | Notes: Set *Extended Key Usage* to *Server Authentication*. 39 | 40 | 3. create end entity profile 41 | 42 | :ejbca:`End Entity Profiles ` 43 | 44 | suggested name: *ACMEEndEntityProfile* 45 | 46 | Notes: add a few *DNS Name* entries to the allowed *Subject Alternative 47 | Name* *Other subject attributes* and under *Main certificate data* set the 48 | CA to the one from Step 1, and the Certificate Profile to the one from Step 2. 49 | 50 | 4. create a cert profile for the api client: 51 | 52 | :ejbca:`Certificate Profiles ` 53 | 54 | suggested name: *APIClientProfile* 55 | 56 | Notes: Set *Extended Key Usage* to *Client Authentication*. The CA should 57 | be the *ManagementCA*. 58 | 59 | 5. create a profile for the client certificate: 60 | 61 | :ejbca:`End Entity Profiles ` 62 | 63 | suggested name: *APIClientEntityProfile* 64 | 65 | Notes: Under *Main certificate data* set to use *APIClientProfile* and 66 | *ManagementCA*. 67 | 68 | 6. create a user for the api: 69 | 70 | :ejbca:`Add End Entity ` 71 | 72 | suggested name: *client01* 73 | 74 | Notes: use the *End Entity Profile* from Step 5 and set Common Name to same 75 | as username. 76 | 77 | 7. create user role for acme-client-cert: 78 | 79 | :ejbca:`Administrator Roles ` 80 | 81 | suggested name: *ACMEUser* 82 | 83 | Notes: Set *Access Rules* using *Advanced Mode* to allow the following_: 84 | - ``/administrator`` 85 | - ``/ca_functionality/create_certificate`` 86 | - ``/ra_functionality/create_end_entity`` 87 | - ``/ra_functionality/edit_end_entity`` 88 | - ``/ca/`` (using CA from Step 1) 89 | - ``/endentityprofilesrules//create_end_entity`` 90 | - ``/endentityprofilesrules//edit_end_entity`` 91 | (using End Entity Profile from Step 3) 92 | 93 | .. _following: https://download.primekey.se/docs/EJBCA-Enterprise/latest/ws/org/ejbca/core/protocol/ws/client/gen/EjbcaWS.html#certificateRequest(org.ejbca.core.protocol.ws.client.gen.UserDataVOWS,java.lang.String,int,java.lang.String,java.lang.String) 94 | 95 | 8. add acmeuser to the new usergroup/role: 96 | 97 | :ejbca:`Administrator Roles ` 98 | 99 | Notes: Set the *Members* of the Administrator Role from Step 7 to match 100 | (e.g. on CN and CA) the client entity from Step 6. 101 | 102 | 9. issue a cert for the user 103 | 104 | :ejbca:`Create Certificate from CSR ` or :ejbca:`EJBCA RA-Request new certificate ` 105 | 106 | Notes: ``openssl req -newkey rsa:2048 -keyout client01.key -out client01.csr -nodes -subj /CN=client01`` 107 | download client01.pem, then ``cat client01.key client01.pem > client01-privpub.pem`` 108 | -------------------------------------------------------------------------------- /docs/genindex.rst: -------------------------------------------------------------------------------- 1 | Index 2 | ===== 3 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Serles: A Tiny and Extensible ACME Server/Proxy 2 | =============================================== 3 | 4 | |travis| |codecov| |pypiver| |pypidl| |pypiwheel| |pypilic| 5 | 6 | |gh_stars| |contributors| |forks| 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | :caption: Contents: 11 | :hidden: 12 | 13 | installation 14 | ejbca-configuration 15 | 16 | api 17 | genindex 18 | 19 | Serles is a tiny ACME-CA implementation to enhance your existing Certificate 20 | Authority infrastructure. Initially developed to support ACME with the Open 21 | Source version of PrimeKey's EJBCA's (ACME support is only available in the 22 | Enterprise version), the software is designed for easy adaptation to other PKI 23 | software/CAs which provide an API to issue certificates. 24 | 25 | We sometimes call it a proxy, as it delegates certificate issuance to your 26 | existing PKI. From a user point of view serles-acme in combination with an EJBCA 27 | instance can be compared to be something like your own private letsencrypt. 28 | 29 | If you want to use another PKI project, feel free to implement your own 30 | backends. Contributions are heavily welcome. 31 | 32 | For whom is this project? 33 | 34 | - You want to build up you own PKI, either for company or home usage 35 | - You want to automate the issuing process for all your devices 36 | - You already using another PKI Software and want to use certbot with it 37 | 38 | .. |travis| image:: https://travis-ci.org/dvtirol/serles-acme.svg?branch=master 39 | :target: https://travis-ci.org/dvtirol/serles-acme 40 | .. |codecov| image:: https://codecov.io/gh/dvtirol/serles-acme/branch/master/graph/badge.svg?token=JVJRKUU6FU 41 | :target: https://codecov.io/gh/dvtirol/serles-acme/branch/master 42 | .. |gh_stars| image:: https://img.shields.io/github/stars/dvtirol/serles-acme.svg?label=github%20%E2%98%85&style=for-the-badge 43 | :target: https://github.com/dvtirol/serles-acme 44 | .. |contributors| image:: https://img.shields.io/github/contributors/dvtirol/serles-acme.svg?style=for-the-badge 45 | :target: https://github.com/dvtirol/serles-acme 46 | .. |forks| image:: https://img.shields.io/github/forks/dvtirol/serles-acme.svg?label=github%20forks&style=for-the-badge 47 | :target: https://github.com/dvtirol/serles-acme 48 | .. |pypidl| image:: https://pypip.in/d/serles-acme/badge.svg 49 | :target: https://pypi.org/project/serles-acme/ 50 | .. |pypiver| image:: https://pypip.in/v/serles-acme/badge.svg 51 | :target: https://pypi.org/project/serles-acme/ 52 | .. |pypiegg| image:: https://pypip.in/egg/serles-acme/badge.svg 53 | :target: https://pypi.org/project/serles-acme/ 54 | .. |pypiwheel| image:: https://pypip.in/wheel/serles-acme/badge.svg 55 | :target: https://pypi.org/project/serles-acme/ 56 | .. |pypilic| image:: https://pypip.in/license/serles-acme/badge.svg 57 | :target: https://pypi.org/project/serles-acme/ 58 | 59 | Architecture 60 | ------------ 61 | 62 | Serles is intended to automate certificate issuance from your existing CA. It 63 | will verify the legitimacy of certificate requests, and (if they are), pass 64 | them on to a plugin/backend. 65 | 66 | .. code-block:: text 67 | 68 | +--------+ +---------+ +---------+ 69 | | | (1) ---{authentication}--> | | | Backend | 70 | | Web | (2) ---{order cert}------> | Serles | | (e.g. | 71 | | Server | <-----{validation}-----(3) | ACME | | EJBCA) | 72 | | | (4) ---{CSR}-------------> | | (5) ---{CSR}---------> | | 73 | +--------+ <-----{certificate}--- (7) +---------+ <--{certificate}-- (6) +---------+ 74 | 75 | The threat model is *execution inside a (trusted) enterprise network*. Yet, care 76 | has been taken when accepting any user data. While there is no user 77 | authentication (i.e. anyone who can access Serles is allowed to ask for 78 | certificates), one may specify to which IP subnets requested domains must 79 | resolve to in order to be granted a certificate. 80 | 81 | Installation 82 | ------------ 83 | 84 | See :ref:`installation`. 85 | 86 | Configuration 87 | ------------- 88 | 89 | The configuration file can be set using the ``CONFIG`` environment variable. If it 90 | is absent, it is loaded from ``/etc/serles.ini``. An extensively commented 91 | example configuration file is included as ``config.ini.example``. You may copy 92 | (and rename) it to the beforementioned location. Serles is compatible with any 93 | WSGI server; please consult your server's manual for its configuration. 94 | 95 | Backends 96 | -------- 97 | 98 | The software ships with one predefined backend, but it is easy to write others. 99 | If you do, please send patches! 100 | 101 | A backend is simply a class (no inheritance required) and has the following methods: 102 | 103 | - a constructor taking the parsed config (``ConfigParser`` object; ``dict``-like) 104 | - a method ``sign(self, csr, subjectDN, subjectAltNames, email)``: 105 | Parameters: 106 | 107 | - ``csr``: the CSR as coming from the client (in DER-encoded PKCS#10 format) 108 | - ``subjectDN``: The CSR's Distinguished Name as a string or, if absent, one 109 | created from the template string in the config file. 110 | - ``subjectAltNames``: a list of domain names (as strings) that are to be 111 | written in the certificate's SAN extension attributes. 112 | - ``email``: the email stored in the requesting account (or None). 113 | Intended to be passed on to the backend for notification of the client. 114 | 115 | Returns: 116 | 117 | - on success, the tuple ``(chain_pkcs7_der, None)`` where ``chain_pkcs7_der`` 118 | is the full DER-encoded PKCS#7 certificate chain. 119 | - on error, the tuple ``(None, error_msg)``, where ``error_msg`` is a string 120 | (possibly forwarded from the backend) that describes why the CSR has been 121 | rejected. This is forwarded to the client in a ``badCSR`` problem document. 122 | 123 | .. code-block:: python 124 | 125 | class SomeBackend: 126 | def __init__(self, config): 127 | self.config = config 128 | def sign(self, csr, subjectDN, subjectAltNames, email): 129 | return None, "not implemented" 130 | 131 | Optionally, one can also inherit from the abstract ``serles.backends.base``: 132 | 133 | .. code-block:: python 134 | 135 | class SomeBackend(serles.backends.base): 136 | def sign(self, csr, subjectDN, subjectAltNames, email): 137 | return None, "not implemented" 138 | 139 | EJBCA SOAP Backend 140 | ~~~~~~~~~~~~~~~~~~ 141 | 142 | All you need is a user that has permission_ to issue certificates. Set up a 143 | Certificate Authority (e.g. testca), an End Entity Profile (e.g. acmeendentity) 144 | and a Certificate Profile (e.g. acmeserverprofile). Set up and enroll a user 145 | with a client certificate which will be used to talk to the API. 146 | 147 | When issuing certificates, the Username and Enrollment Code will be generated 148 | from a template. This template can be configured in the config; you can use 149 | parameters from the Distinguished Name (from CSR) by wrapping them in curly 150 | brackets. 151 | 152 | If the client sets a contact email, we will pass it on to EJBCA when forwarding 153 | the CSR. EJBCA can then be configured to send notifications for the 154 | EndEntityProfile. 155 | 156 | .. _permission: https://download.primekey.se/docs/EJBCA-Enterprise/latest/ws/org/ejbca/core/protocol/ws/client/gen/EjbcaWS.html#certificateRequest(org.ejbca.core.protocol.ws.client.gen.UserDataVOWS,java.lang.String,int,java.lang.String,java.lang.String) 157 | 158 | Dependencies 159 | ------------ 160 | 161 | Dependencies are stated in ``setup.py``. If the available python-cryptography 162 | version is less than 3.1, the openssl command line utility (somewhere in 163 | ``$PATH``) is required. 164 | 165 | Notes on threads and databases 166 | ------------------------------ 167 | 168 | The database is used to hold the state between requests, but once an order has 169 | been fulfilled (or rejected), all data relating to it is no longer used (and 170 | actually deleted when the order expires, 7 days after its creation). It is 171 | therefore sufficient to store this database in-memory. However, this in-memory 172 | database is not thread safe. Depending on your requirements, either set 173 | ``database`` in ``config.ini`` to a on-disk DB, or (when using gunicorn) limit 174 | the number of worker processes and threads to 1. 175 | 176 | Note that certbot tries to re-use account IDs, so when using an in-memory DB 177 | pass ``--pre-hook 'rm -rf /etc/letsencrypt/accounts'`` to it, to avoid this 178 | behaviour. 179 | 180 | Note that when using the EJBCA backend, you should only allow a single 181 | connection at a time (i.e. single-threading), since there are concurrency 182 | problems in the EJBCA software. 183 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. |ejbca-host| replace:: localhost:9443 2 | 3 | .. _installation: 4 | 5 | Setting up Serles with Gunicorn 6 | =============================== 7 | 8 | In this document we will describe a production-ready setup of Serles using 9 | Gunicorn. 10 | 11 | 1. Installation 12 | --------------- 13 | 14 | .. code-block:: shell 15 | 16 | python3 -m venv /opt/serles_venv 17 | . /opt/serles_venv/bin/activate 18 | python3 -m pip install serles-acme 19 | 20 | In order to run Serles in production, you will need a WSGI HTTP(s) server. We 21 | chose gunicorn for this example, which ships with Serles. You do not have to 22 | use a virtual environment; all dependencies should also be packaged by your 23 | distribution. 24 | 25 | 2. Configuration 26 | ---------------- 27 | 28 | Copy the (fully commented) sample configuration file ``config.ini.example`` to 29 | ``/etc/serles/config.ini`` and modify it to suit your environment. 30 | 31 | The included ``/bin/serles`` executable will load gunicorn configuration from 32 | ``/etc/serles/gunicorn_config.py``. 33 | 34 | For gunicorn, the ``APP_MODULE`` string is ``serles:create_app()``. 35 | Please see the `gunicorn configuration documentation 36 | `_ for TLS and port binding. 37 | 38 | Below is an example systemd unit file, that uses its own gunicorn from a 39 | virtual environment: 40 | 41 | .. code-block:: none 42 | 43 | [Unit] 44 | Description=gunicorn daemon for Serles 45 | After=network.target 46 | 47 | [Service] 48 | PIDFile=/run/acmeproxy/pid 49 | RuntimeDirectory=acmeproxy 50 | Environment="PATH=/opt/serles_venv/bin:/usr/bin" 51 | ExecStart=/opt/serles_venv/bin/gunicorn -c /etc/serles/gunicorn_config.py "serles:create_app()" 52 | ExecReload=/bin/kill -HUP $MAINPID 53 | PrivateTmp=true 54 | 55 | [Install] 56 | WantedBy=multi-user.target 57 | 58 | Note that the selected backend will have to be configured as well; for the 59 | included EJBCA backend see for example :ref:`ejbca-configuration`. 60 | -------------------------------------------------------------------------------- /examples-clients/README.md: -------------------------------------------------------------------------------- 1 | # Examples using different ACME clients 2 | 3 | This directory contains a few scripts that ask the Serles ACME Server for a certificate. Some require a CSR; see `gen-csr.sh`. 4 | 5 | These scripts will not run out of the box, but are supposed to be taken as 6 | inspiration. 7 | 8 | - [certbot] 9 | - [ansible] 10 | - [acme-tiny] 11 | - [acme.sh] 12 | 13 | [acme-tiny]: https://github.com/diafygi/acme-tiny 14 | [acme.sh]: https://github.com/acmesh-official/acme.sh 15 | [certbot]: https://certbot.eff.org/docs/using.html 16 | [ansible]: https://docs.ansible.com/ansible/latest/modules/acme_certificate_module.html 17 | -------------------------------------------------------------------------------- /examples-clients/acme_sh.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # this acme client isn't great :/ 4 | 5 | export http_proxy= 6 | export https_proxy= 7 | 8 | sudo true || exit 99 # warm up sudo, since next call is backgrounded 9 | sudo python3 -m http.server 80 & 10 | httpd=$? 11 | 12 | cd $(dirname $(realpath $0)) 13 | ./acme.sh/acme.sh --issue -d example.test -d www.example.test -w . --server https://localhost:8443/directory --insecure --force # force to dont wait for expiry 14 | 15 | sudo kill $httpd 16 | -------------------------------------------------------------------------------- /examples-clients/acme_tiny.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # create a csr with gen-csr.sh first! 4 | 5 | export http_proxy= 6 | export https_proxy= 7 | 8 | mkdir -p /tmp/well-known/.well-known/acme-challenge 9 | sudo true || exit 99 # warm up sudo, since next call is backgrounded 10 | ( cd /tmp/well-known && sudo python3 -m http.server 80; ) & 11 | httpd=$? 12 | 13 | cd $(dirname $(realpath $0)) 14 | cd acme-tiny-master/ 15 | python3 ./acme_tiny.py \ 16 | --directory-url https://localhost:8443/directory \ 17 | --acme-dir /tmp/well-known/.well-known/acme-challenge/ \ 18 | --account-key ../altcert/example.test.key \ 19 | --csr ../altcert/example.test.csr \ 20 | #--contact certmaster@example.test test@example.org 21 | 22 | sudo kill $httpd 23 | -------------------------------------------------------------------------------- /examples-clients/ansible.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: example.int.dvt.at 3 | 4 | # Note: we generate two private keys: privkey-account.pem is used by the acme_certificate module to talk to the acme server, privkey.pem is the private key of the tls certificate. 5 | 6 | tasks: 7 | - name: Generate a private key for the acme client 8 | openssl_privatekey: 9 | path: /tmp/privkey-account.pem 10 | size: 3072 11 | 12 | - name: Generate a private key for the TLS cert 13 | openssl_privatekey: 14 | path: /etc/pki/tls/private/example.int.dvt.at.key 15 | size: 3072 16 | 17 | - name: Generate a CSR. ACME_CERTIFICATE expects the correct CN and SANs present! 18 | openssl_csr: 19 | path: /etc/pki/tls/private/example.int.dvt.at.csr 20 | privatekey_path: /tmp/privkey-account.pem 21 | # email_address: netz@dvt.at # muss nicht angegeben werden; falls angegeben, wird an EJBCA weitergereicht 22 | common_name: example.int.dvt.at 23 | subject_alt_name: 'DNS:www.example.int.dvt.at,DNS:example.int.dvt.at' 24 | 25 | - name: "get challenges" 26 | acme_certificate: 27 | account_key_src: /tmp/privkey-account.pem 28 | csr: /etc/pki/tls/private/example.int.dvt.at.csr 29 | dest: /etc/pki/tls/certs/example.int.dvt.at.crt 30 | acme_directory: https://acme.intra.tirol.gv.at:8443/directory 31 | acme_version: 2 32 | register: acme_challenge 33 | 34 | - file: path=/var/www/.well-known/acme-challenge state=directory 35 | - copy: 36 | #dest: /var/www/{{ item.key }}/{{ item.value['http-01']['resource'] }} 37 | dest: "/var/www/{{ item.value['http-01']['resource'] }}" 38 | content: "{{ item.value['http-01']['resource_value'] }}" 39 | loop: "{{ acme_challenge.challenge_data |default({})| dict2items }}" 40 | when: acme_challenge is changed 41 | 42 | - name: get certificate 43 | acme_certificate: 44 | account_key_src: /tmp/privkey-account.pem 45 | csr: /etc/pki/tls/private/example.int.dvt.at.csr 46 | dest: /etc/pki/tls/certs/example.int.dvt.at.crt 47 | acme_directory: https://acme.intra.tirol.gv.at:8443/directory 48 | acme_version: 2 49 | data: "{{ acme_challenge }}" 50 | when: acme_challenge is changed 51 | -------------------------------------------------------------------------------- /examples-clients/certbot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | sudo certbot certonly \ 4 | --standalone \ 5 | --server https://localhost:8443/directory `#URL of your ACMEByProxy server` \ 6 | --pre-hook 'rm -rf /etc/letsencrypt/accounts' `#certbot tries to reuse account keys, but we don't store them` \ 7 | --force-renewal \ 8 | --agree-tos \ 9 | --no-eff-email \ 10 | --reuse-key \ 11 | -vv \ 12 | -m 'me+noemail@localhost' \ 13 | -d letsdane \ 14 | --no-verify-ssl # XXX: very bad, no good idea! 15 | -------------------------------------------------------------------------------- /examples-clients/exos-ansible-acme/acme_port80.py: -------------------------------------------------------------------------------- 1 | # challenge verification server. to be temporarily started by ansible. 2 | # workflow: 3 | # 1. use "configure ssl csr ..." to create a csr 4 | # 2. using a python script, copy the privkey to /config/ 5 | # 3. download the csr to the ansible host 6 | # 4. use ansible_acme to fetch challenge data 7 | # 5. upload challenge data to 8 | # create process acme_port80 python-module acme_port80 start auto 9 | 10 | import cherrypy 11 | import sys 12 | import urlparse 13 | 14 | sys.path.append("/config") 15 | import acme_tokens 16 | 17 | tokens = {t.split(".")[0]: t for t in acme_tokens.tokens} 18 | 19 | 20 | def httpd_service(environ, start_response): 21 | _, _, path, _, _, _ = urlparse.urlparse(environ.get("REQUEST_URI", "/")) 22 | 23 | if path == "/": 24 | start_response("200 OK", []) 25 | return "OK" 26 | 27 | parts = path.split("/") 28 | assert parts[0] == "" 29 | assert parts[1] == ".well-known" 30 | assert parts[2] == "acme-challenge" 31 | token = parts[3] 32 | 33 | start_response("200 OK", []) 34 | return tokens.get(token, "") 35 | 36 | 37 | from cherrypy._cpserver import Server 38 | 39 | cherrypy.server.unsubscribe() # don't start default http server that listens on some socket 40 | cherrypy.config.update({"/": {}}) 41 | 42 | server = Server(exos_vr="ALL") 43 | server._socket_host = "::" 44 | server.socket_port = 80 45 | 46 | server.thread_pool = 7 47 | server.subscribe() 48 | 49 | cherrypy.tree.graft(httpd_service, "/") 50 | 51 | cherrypy.engine.start() 52 | server.start() 53 | cherrypy.engine.block() 54 | -------------------------------------------------------------------------------- /examples-clients/exos-ansible-acme/exosv4.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: ad22-1ug2a-2.net.tirol.local 3 | connection: network_cli 4 | user: ansiblerw 5 | 6 | vars: 7 | ansible_network_os: exos 8 | cert_store: "/opt/ansible/acme-exos-issue-certifiacates" 9 | acme_server: https://acme.intra.tirol.gv.at:8443/directory 10 | renew_before_days: 30 # renew when cert will expire in < X days 11 | 12 | tasks: 13 | - name: Gather all legacy facts 14 | exos_facts: # WITHOUT THIS, EXOS-TASKS (e.g. net_put) FAIL SOMETIMES! 15 | gather_subset: default 16 | 17 | # you can call this task with --extra_vars (or -e) force_renewal= to force reissuning of certificates. 18 | # WARNING: always use also --limit, otherwise ALL Switches get new certs! 19 | 20 | - name: upload helper scripts to switch 21 | net_put: 22 | src: "{{ item.src }}" 23 | dest: "{{ item.dest }}" 24 | protocol: sftp 25 | with_items: 26 | - { src: "files/acme_port80.py", dest: "/config/acme_port80.py" } 27 | # - { src: "files/acme_startp80.py", dest: "/config/acme_startp80.py" } 28 | 29 | - name: make sure cert store exists 30 | file: 31 | path: "{{ cert_store }}/{{ common_name }}" 32 | state: directory 33 | 34 | # I: check certificate expiry (if not exist, assume expired) 35 | - name: check certificate expiry 36 | # Note: this would fail when the cert doesn't exist, so we overwrite the 37 | # failed_when condition to never fail. if the cert didn't exist, 38 | # cert_info.expired will be undefined, otherwise cert_info.expired will be 39 | # a boolean. 40 | openssl_certificate_info: 41 | path: "{{ cert_store }}/{{ common_name }}/cert.pem" 42 | valid_at: 43 | point_1: "+{{ renew_before_days }}d" 44 | failed_when: false 45 | register: cert_info 46 | - set_fact: 47 | cert_expired: "{{ force_renewal is defined or cert_info.valid_at.point_1 is not defined or not cert_info.valid_at.point_1 }}" 48 | 49 | 50 | # -- if not expired, we can skip everything. 51 | 52 | - name: prepare local temporary directory 53 | command: mktemp -d 54 | register: tempdir 55 | when: cert_expired 56 | 57 | # II: if expired, create a new private key for the switch 58 | - name: generate private key for the switch 59 | openssl_privatekey: 60 | # Note: there is a way to generate private keys on the device ("configure 61 | # ssl csr ..."), but the generated CSR mangles the common name. since 62 | # we'd have to export the private key due to this anyway, we can just 63 | # take the easy path and generate it directly on the ansible-host. 64 | # Note: openssl_privatekey generates the key in-ram on the ansible-host 65 | # and writes it to the remote. writing to exos switches does not work, so 66 | # we have to write it to the ansible-host and manually transfer it over. 67 | path: "{{ tempdir.stdout }}/privkey-server.pem" 68 | size: 3072 69 | delegate_to: localhost 70 | when: cert_expired 71 | 72 | # III: if expired, create a CSR for the switch hostname 73 | - name: Generate a Certificate Signing Request with Subject information 74 | openssl_csr: 75 | path: "{{ cert_store }}/{{ common_name }}/csr.pem" 76 | privatekey_path: "{{ tempdir.stdout }}/privkey-server.pem" 77 | country_name: AT 78 | organization_name: DVT - Daten-Verarbeitung-Tirol GmbH 79 | #email_address: 80 | common_name: "{{ common_name }}" 81 | subject_alt_name: "DNS:{{ common_name }}" 82 | delegate_to: localhost 83 | when: cert_expired 84 | 85 | # IV: if expired, upload new privkey to switch 86 | - name: create cert dir on switch 87 | exos_config: 88 | commands: mkdir acme_ssl 89 | # XXX: this always says changed! 90 | when: cert_expired 91 | 92 | - name: upload private key to switch 93 | net_put: 94 | src: "{{ tempdir.stdout }}/privkey-server.pem" 95 | dest: /config/acme_ssl/pkey.pem 96 | protocol: sftp 97 | when: cert_expired 98 | 99 | # V: delete private key (if exists) 100 | - name: purge privkey from ansible host 101 | command: "shred -u {{ tempdir.stdout }}/privkey-server.pem" 102 | delegate_to: localhost 103 | failed_when: false # do it even if not generated, just for good measure. 104 | when: tempdir.stdout is defined 105 | 106 | - name: generate acme client account private key 107 | # this is used for communication between ansible-host and acme-proxy and is 108 | # not considered sensitive (you can't get any secret infos from it). 109 | openssl_privatekey: 110 | path: "{{ tempdir.stdout }}/acme-account-key.pem" 111 | size: 3072 112 | delegate_to: localhost 113 | when: cert_expired 114 | 115 | # VI: if expired, get challenges for freshly created CSR 116 | - name: get challenges 117 | acme_certificate: 118 | account_key_src: "{{ tempdir.stdout }}/acme-account-key.pem" 119 | csr: "{{ cert_store }}/{{ common_name }}/csr.pem" 120 | fullchain_dest: "{{ cert_store }}/{{ common_name }}/cert.pem" 121 | acme_directory: "{{ acme_server }}" 122 | remaining_days: "{{ renew_before_days }}" 123 | acme_version: 2 124 | force: "{{ force_renewal is defined }}" 125 | delegate_to: localhost 126 | register: acme_challenges 127 | when: cert_expired 128 | 129 | # VII: if expired, start port80 server with challenge-config parameters 130 | - name: create config file for port80 131 | copy: # WARN: only reads the first token! 132 | content: "tokens = ['{{(acme_challenges.challenge_data|default({})|dict2items)[0].value['http-01']['resource_value']}}']" 133 | dest: "{{ tempdir.stdout }}/challenges_config.py" 134 | when: cert_expired and acme_challenges is changed 135 | 136 | - name: upload challenge config 137 | net_put: 138 | src: "{{ tempdir.stdout }}/challenges_config.py" 139 | dest: /config/acme_tokens.py 140 | protocol: sftp 141 | when: cert_expired and acme_challenges is changed 142 | 143 | - name: configure and start port80 webserver 144 | exos_config: # WARN: only reads the first token! 145 | commands: 146 | - "create process acme_port80 python-module acme_port80 start on-demand vr VR-Mgmt" 147 | - "start process acme_port80" 148 | save_when: changed 149 | when: cert_expired and acme_challenges is changed 150 | 151 | # VIII: if expired, wait for port80 to accept connections 152 | - name: wait for port80 to come up 153 | uri: # https://gist.github.com/mikeifomin/67e233cd461331de16707ef59a07e372 154 | url: "http://{{ common_name }}:80/" 155 | status_code: 200 156 | register: result 157 | until: result.status == 200 158 | retries: 60 159 | delay: 1 160 | when: cert_expired and acme_challenges is changed 161 | 162 | # IX: if expired, let acme-server verify challenges, download fullchain to ansible-host 163 | - name: get certificate (to local) 164 | acme_certificate: 165 | account_key_src: "{{ tempdir.stdout }}/acme-account-key.pem" 166 | csr: "{{ cert_store }}/{{ common_name }}/csr.pem" 167 | fullchain_dest: "{{ cert_store }}/{{ common_name }}/cert.pem" 168 | acme_directory: "{{ acme_server }}" 169 | remaining_days: "{{ renew_before_days }}" 170 | acme_version: 2 171 | force: "{{ force_renewal is defined }}" 172 | data: "{{ acme_challenges }}" 173 | delegate_to: localhost 174 | when: cert_expired and acme_challenges is changed 175 | 176 | # -- WARN: if the play fails between these(IX/X) steps, the logic of step 177 | # (I) thinks the cert is OK, while it wasn't uploaded. delete the local cert 178 | # and retry. 179 | 180 | # X: if expired, upload fullchain to switch 181 | - name: upload certificate to switch 182 | net_put: 183 | src: "{{ cert_store }}/{{ common_name }}/cert.pem" 184 | dest: /config/acme_ssl/cert.pem 185 | protocol: sftp 186 | when: cert_expired and acme_challenges is changed 187 | 188 | # XI: stop port80 server 189 | - name: stop port80 webserver 190 | exos_config: 191 | commands: 192 | - terminate process acme_port80 forceful # graceful not enough 193 | - delete process acme_port80 194 | save_when: changed 195 | when: cert_expired and acme_challenges is changed 196 | 197 | ### Zertifikats-Wechsel passiert automatisch - kein service restart nötig. 198 | # - name: restart macauSwitchSwitchAPI to use new cert 199 | # exos_config: 200 | # commands: restart process macauSwitchSwitchApi 201 | # ignore_errors: yes 202 | # when: cert_expired and acme_challenges is changed 203 | 204 | - name: tear down temporary directory 205 | file: 206 | path: "{{ tempdir.stdout }}" 207 | state: absent 208 | when: cert_expired 209 | 210 | -------------------------------------------------------------------------------- /examples-clients/gen-csr.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | openssl req -new -sha256 -nodes -out example.test.csr -newkey rsa:2048 -keyout example.test.key -config <( 3 | cat <<-EOF 4 | [req] 5 | default_bits = 2048 6 | prompt = no 7 | default_md = sha256 8 | req_extensions = req_ext 9 | distinguished_name = dn 10 | 11 | [ dn ] 12 | #C=US 13 | #ST=New York 14 | #L=Rochester 15 | #O=End Point 16 | #OU=Testing Domain 17 | #emailAddress=your-administrative-address@your-awesome-existing-domain.com 18 | CN = example.test 19 | 20 | [ req_ext ] 21 | subjectAltName = @alt_names 22 | 23 | [ alt_names ] 24 | DNS.1 = example.test 25 | DNS.2 = www.example.test 26 | EOF 27 | ) 28 | 29 | -------------------------------------------------------------------------------- /serles/__init__.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from flask import Flask, render_template, jsonify 3 | import re 4 | 5 | from .utils import background_job, base64d, query, get_ptr, ip_in_ranges, normalize 6 | from .configloader import get_config 7 | from .views import * # Note: import views before models! 8 | from .models import * 9 | from .exceptions import ACMEError 10 | from .flask_handlers import parse_jws, inject_nonce, index_header, exception_handler 11 | 12 | 13 | def create_app(): 14 | """ initialize web app 15 | 16 | This function should be passed to the WSGI server. 17 | """ 18 | config, _ = get_config() 19 | 20 | app = Flask(__name__) 21 | app.config["PROPAGATE_EXCEPTIONS"] = True # makes @app.errorhandler handle events 22 | app.config["SQLALCHEMY_DATABASE_URI"] = config["database"] 23 | app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 24 | app.config["SERVER_NAME"] = config["server_name"] 25 | 26 | init_config() # views.init_config() 27 | api.init_app(app) 28 | db.init_app(app) 29 | db.create_all(app=app) # Note: model classes must be defined at this point 30 | 31 | @app.route('/') 32 | def HomePage(): 33 | return render_template('home.html') 34 | 35 | @app.route('/tlsa') 36 | def TLSAPage(): 37 | return render_template('tlsa.html') 38 | 39 | @app.route('/tlsa/') 40 | def TLSADomain(domain): 41 | if not re.match(r'^[a-zA-Z0-9_\-.]+$', domain): 42 | res = jsonify({'error': 'Invalid domain.'}) 43 | res.status_code = 400 44 | return res 45 | try: 46 | tlsa = utils.get_tlsa_remote_host(domain) 47 | except Exception as e: 48 | print(e) 49 | res = jsonify({'error': 'Could not connect / fetch TLSA.'}) 50 | res.status_code = 500 51 | return res 52 | 53 | return {'tlsa': tlsa} 54 | 55 | app.register_error_handler(Exception, exception_handler) 56 | app.before_request(parse_jws) 57 | app.after_request(inject_nonce) 58 | app.after_request(index_header) 59 | 60 | @background_job(60) # purge unused nonces every minute (keeps database small) 61 | def purge_nonces(): 62 | with app.app_context(): 63 | Nonces.purge_expired() 64 | 65 | @background_job(24 * 60 * 60) # once daily, remove expired Orders 66 | def purge_orders(): 67 | with app.app_context(): 68 | for order in Order.query.filter( 69 | Order.expires < datetime.now(timezone.utc) 70 | ).all(): 71 | db.session.delete(order) 72 | db.session.commit() 73 | 74 | return app 75 | -------------------------------------------------------------------------------- /serles/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from serles import create_app 4 | 5 | if __name__ == "__main__": 6 | create_app().run(host="::0", port=8443, ssl_context="adhoc") 7 | -------------------------------------------------------------------------------- /serles/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htools-org/htools-dane-ca/72d9c396fe48422cfbf9e858a79cdbfaeda92437/serles/backends/__init__.py -------------------------------------------------------------------------------- /serles/backends/base.py: -------------------------------------------------------------------------------- 1 | # to implement your own backend, create a class Backend with a method sign(self,DER_CSR,DN,[SAN],email) that returns (PKCS7_fullchain,error_or_None). 2 | import base64 3 | import csv 4 | import requests 5 | import secrets 6 | import zeep # fedora package: python3-zeep.noarch 7 | 8 | from abc import ABC, abstractmethod 9 | 10 | 11 | class Backend(ABC): 12 | """ Abstract Base Backend 13 | 14 | Inherit from this and implement ``sign()``, and optionally ``__init__()`` 15 | to use this. 16 | """ 17 | 18 | def __init__(self, config): 19 | self.config = config 20 | 21 | @abstractmethod 22 | def sign(self, csr, subjectDN, subjectAltNames, email): 23 | pass 24 | -------------------------------------------------------------------------------- /serles/backends/dane.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from datetime import timedelta 3 | import uuid 4 | import tempfile 5 | import hashlib 6 | import requests 7 | import subprocess 8 | import json 9 | from subprocess import Popen, PIPE, DEVNULL 10 | 11 | from cryptography import x509 12 | from cryptography.x509.oid import NameOID 13 | from cryptography.hazmat.backends import default_backend 14 | from cryptography.hazmat.primitives import hashes 15 | from cryptography.hazmat.primitives.asymmetric import rsa 16 | from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat 17 | 18 | 19 | # https://freeoid.pythonanywhere.com/ 20 | EXTENSION_ID_BY_NAME = { 21 | "UrkelProof": "1.3.6.1.4.1.54392.5.1620", 22 | "DnssecChain": "1.3.6.1.4.1.54392.5.1621", 23 | } 24 | 25 | 26 | class DaneBackend(object): 27 | def __init__(self, config): 28 | self.config = config 29 | self.send_emails = config["sendgrid"].get( 30 | "send_emails", "false") == "true" 31 | 32 | def sign(self, csr, subjectDN, subjectAltNames, email): 33 | print("New request:", subjectDN, subjectAltNames, email) 34 | 35 | # Load CSR 36 | csr_obj = x509.load_der_x509_csr(csr, backend=default_backend()) 37 | 38 | subject = issuer = x509.Name([ 39 | x509.NameAttribute(NameOID.COMMON_NAME, subjectDN), 40 | ]) 41 | 42 | # Generate temporary CA 43 | print("Generating ephemeral CA...") 44 | ca_cert, ca_privkey = self.generate_ephemeral_ca() 45 | 46 | # Build certificate 47 | print("Building certificate...") 48 | certificate = x509.CertificateBuilder().subject_name( 49 | subject 50 | ).issuer_name( 51 | issuer 52 | ).public_key( 53 | csr_obj.public_key() 54 | ).serial_number( 55 | x509.random_serial_number() 56 | ).add_extension( 57 | x509.SubjectAlternativeName( 58 | [x509.DNSName(name) for name in subjectAltNames]), 59 | critical=False, 60 | ) 61 | 62 | # Experimental HIP-0017 Certificate (Stateless DANE) 63 | if email and "+nohip17" not in email: 64 | try: 65 | print("Attempting to fetch HIP-17 extensions...") 66 | hip17_exts = self.get_hip17_extensions(subjectDN) 67 | for ext in hip17_exts: 68 | certificate = certificate.add_extension(ext, False) 69 | # shorter certificates 70 | certificate = certificate.not_valid_before( 71 | datetime.datetime.utcnow() - timedelta(days=1) 72 | ).not_valid_after( 73 | datetime.datetime.utcnow() + timedelta(days=2) 74 | ) 75 | print("Successfully added HIP-17 extensions!") 76 | except Exception as e: 77 | print("Error fetching HIP-17 extensions:") 78 | print(e) 79 | # Only error if HIP-17 was explicitly requested 80 | # Otherwise ignore silently and continue with non-HIP-17 cert 81 | if "+hip17" in email: 82 | raise e 83 | else: 84 | print("Silently ignoring HIP-17 error.") 85 | else: 86 | if email: 87 | print("Skipping HIP-17, was explicitly requested.") 88 | 89 | # Set dates if not HIP-17 90 | if certificate._not_valid_before is None: 91 | if email and '+longttl' in email: 92 | certificate = certificate.not_valid_before( 93 | datetime.datetime.utcnow() - timedelta(days=1) 94 | ).not_valid_after( 95 | datetime.datetime.utcnow() + timedelta(days=365) 96 | ) 97 | else: 98 | certificate = certificate.not_valid_before( 99 | datetime.datetime.utcnow() - timedelta(days=1) 100 | ).not_valid_after( 101 | datetime.datetime.utcnow() + timedelta(days=2) 102 | ) 103 | 104 | # Sign certificate with CA"s key 105 | print("Signing certificate...") 106 | certificate = certificate.sign( 107 | ca_privkey, hashes.SHA256(), backend=default_backend()) 108 | 109 | # Bundle domain and CA certificate into fullchain (PKCS#7, DER) 110 | print("Bundling certificates...") 111 | bundle = self.create_fullchain([ 112 | certificate.public_bytes(Encoding.PEM), 113 | ca_cert.public_bytes(Encoding.PEM) 114 | ]) 115 | 116 | if email and "+email" in email and self.send_emails: 117 | # TLSA 118 | cert_bytes = certificate.public_key().public_bytes( 119 | Encoding.DER, PublicFormat.SubjectPublicKeyInfo) 120 | tlsa_digest = hashlib.sha256(cert_bytes).hexdigest() 121 | print("TLSA:", f"_443._tcp.{subjectDN}. TLSA 3 1 1 {tlsa_digest}") 122 | 123 | # Send email 124 | try: 125 | self.send_cert_issue_email(email, subjectDN, tlsa_digest) 126 | pass 127 | except Exception as e: 128 | print(e) 129 | 130 | print("Done!") 131 | return (bundle, None) 132 | 133 | def generate_ephemeral_ca(self): 134 | 135 | # Generate CA"s key pair 136 | private_key = rsa.generate_private_key( 137 | public_exponent=65537, 138 | key_size=2048, 139 | backend=default_backend() 140 | ) 141 | public_key = private_key.public_key() 142 | 143 | subject = issuer = x509.Name([ 144 | x509.NameAttribute(NameOID.COMMON_NAME, 145 | u"Handshake Tools Ephemeral CA"), 146 | x509.NameAttribute(NameOID.ORGANIZATION_NAME, 147 | u"Handshake Tools"), 148 | x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, 149 | u"ACME"), 150 | ]) 151 | 152 | # Build CA certificate 153 | builder = x509.CertificateBuilder().subject_name( 154 | subject 155 | ).issuer_name( 156 | issuer 157 | ).not_valid_before( 158 | datetime.datetime.utcnow() - timedelta(days=1) 159 | ).not_valid_after( 160 | datetime.datetime.utcnow() + timedelta(days=365) 161 | ).serial_number( 162 | int(uuid.uuid4()) 163 | ).public_key( 164 | public_key 165 | ).add_extension( 166 | x509.BasicConstraints(ca=True, path_length=None), critical=True, 167 | ) 168 | 169 | # Sign CA certificate with CA key 170 | certificate = builder.sign( 171 | private_key=private_key, algorithm=hashes.SHA256(), backend=default_backend() 172 | ) 173 | 174 | return certificate, private_key 175 | 176 | def create_fullchain(self, certs): 177 | # OpenSSL only reads certificates only from files and not stdin, 178 | # so we write them to NamedTemporaryFiles which are deleted on close 179 | files = [] 180 | certfile_args = [] 181 | for cert in certs: 182 | f = tempfile.NamedTemporaryFile() 183 | f.write(cert) 184 | f.flush() 185 | certfile_args += ["-certfile", f.name] 186 | files.append(f) 187 | 188 | proc = Popen( 189 | ["openssl", "crl2pkcs7", "-nocrl", "-outform", "DER"] + certfile_args, 190 | stdin=PIPE, 191 | stdout=PIPE, 192 | stderr=DEVNULL, 193 | ) 194 | pem_cert = proc.stdout.read() 195 | 196 | for file in files: 197 | file.close() 198 | 199 | return pem_cert 200 | 201 | def send_cert_issue_email(self, email, domain, digest): 202 | headers = { 203 | "Authorization": f"Bearer {self.config['sendgrid']['api_key']}", 204 | "Content-Type": "application/json" 205 | } 206 | 207 | payload = { 208 | "template_id": self.config["sendgrid"]["template_id"], 209 | "personalizations": [{ 210 | "dynamic_template_data": { 211 | "domain": domain, 212 | "digest": digest 213 | }, 214 | "to": [{"email": email}] 215 | }], 216 | "from": { 217 | "name": self.config["sendgrid"]["from_name"], 218 | "email": self.config["sendgrid"]["from_email"] 219 | }, 220 | "asm": { 221 | "group_id": int(self.config["sendgrid"]["asm_group_id"]) 222 | } 223 | } 224 | r = requests.post("https://api.sendgrid.com/v3/mail/send", 225 | headers=headers, json=payload) 226 | 227 | if r.status_code not in [200, 202]: 228 | print(r.status_code) 229 | print(r.text) 230 | 231 | def get_hip17_extensions(self, name: str): 232 | proc = subprocess.run( 233 | ["stateless-dane", "get-ext-data", name, "--parsed", "false"], capture_output=True, text=True) 234 | 235 | if proc.stderr: 236 | print(proc.stderr) 237 | raise Exception( 238 | 'Error when building HIP-17 certificate. Check log for more details.') 239 | 240 | data = json.loads(proc.stdout) 241 | # print(data) 242 | 243 | extensions = [] 244 | 245 | for el in data: 246 | ext_id_value = EXTENSION_ID_BY_NAME.get(el["extnID"]) 247 | extensions.append( 248 | x509.UnrecognizedExtension( 249 | oid=x509.ObjectIdentifier(ext_id_value), 250 | value=bytes.fromhex(el["extnValue"]) 251 | ) 252 | ) 253 | 254 | # print(extensions) 255 | return extensions 256 | -------------------------------------------------------------------------------- /serles/backends/ejbca.py: -------------------------------------------------------------------------------- 1 | # to implement your own backend, create a class Backend with a method sign(self,DER_CSR,DN,[SAN],email) that returns (PKCS7_fullchain,error_or_None). 2 | import base64 3 | import csv 4 | import requests 5 | import secrets 6 | import zeep # fedora package: python3-zeep.noarch 7 | 8 | 9 | class EjbcaBackend: 10 | """ ACMEByProxy Backend for EJBCA (Community edition compatible) 11 | 12 | Uses the EJBCA SOAP API to request certificates. Recommended setup on the 13 | EJBCA side: 14 | 15 | - A Certificate Profile for ACME-issued certificates, e.g. 16 | "ACMEServerProfile". Must have an Extended Key Usage of "Server 17 | Authentication". Should have relatively short Validity duration. 18 | - An End Entity Profile, e.g. "ACMEEndIdentityProfile". Uses the 19 | Server Certificate Profile mentioned above and should allow for a number 20 | of DNS Name Subject Alternative Names. 21 | 22 | To connect to the API, the following setup is used: 23 | 24 | - A Certificate Profile for authenticating EjbcaBackend with EJBCA's SOAP 25 | API, e.g. "APIClientProfile". Must have an Extended Key Usage of "Client 26 | Authentication". 27 | - An End Entity Profile for client authentication, e.g. 28 | "APIClientIdentityProfile". Uses the Client Certificate Profile mentioned 29 | above. 30 | - A concrete End Entity for EjbcaBackend, e.g. "client01". Uses the Client 31 | Entity Profile mentioned above. Should have its common name same as its 32 | user name. 33 | - A certificate must be issued for this entity and its location stored in 34 | config.ini. 35 | - An Administrator Role for API clients, e.g. "ACMEUser". From advanced 36 | mode, requires access to the Rules (`<>` denote variables) 37 | 38 | - ``/administrator`` 39 | - ``/ca_functionality/create_certificate`` 40 | - ``/ra_functionality/create_end_entity`` 41 | - ``/ra_functionality/edit_end_entity`` 42 | - ``/ca/`` (e.g. ``/ca/ACMECA``) 43 | - ``/endentityprofilesrules//create_end_entity`` 44 | (e.g. ``/endentityprofilesrules/ACMEEndIdentityProfile/create_end_entity/``) 45 | - ``/endentityprofilesrules//edit_end_entity`` 46 | (e.g. ``/endentityprofilesrules/ACMEEndIdentityProfile/edit_end_entity/``) 47 | - Ensure the client entity is in the correct Administrator Role (e.g. via CN). 48 | """ 49 | 50 | def __init__(self, config): 51 | try: 52 | clientCertificate = config["backend"]["clientCertificate"] 53 | apiUrl = config["backend"]["apiUrl"] 54 | caBundle = config["backend"]["caBundle"] 55 | caBundle = dict(default=True, none=False).get(caBundle, caBundle) 56 | self.caName = config["backend"]["caName"] 57 | self.endEntityProfileName = config["backend"]["endEntityProfileName"] 58 | self.certificateProfileName = config["backend"]["certificateProfileName"] 59 | self.entityUsernameScheme = config["backend"]["entityUsernameScheme"] 60 | self.entityPasswordScheme = config["backend"]["entityPasswordScheme"] 61 | except KeyError as e: 62 | raise Exception(f"missing config key {e}") 63 | 64 | session = requests.Session() 65 | session.verify = caBundle 66 | session.cert = clientCertificate 67 | transport = zeep.transports.Transport(session=session) 68 | 69 | self.client = zeep.Client(apiUrl, transport=transport) 70 | self.userData = self.client.get_type("ns0:userDataVOWS") 71 | 72 | def sign(self, csr, subjectDN, subjectAltNames, email): 73 | subjectAltName = ",".join(f"DNSNAME={name}" for name in subjectAltNames) 74 | 75 | # NOTE: this is very hacky and not to spec/rfc4514, but should be 76 | # enough to extract the CN. Notably, we don't support "+" and "\". 77 | dn = next(csv.reader([subjectDN], escapechar="\\", doublequote=False)) 78 | dn = {part.partition("=")[0]: part.partition("=")[2] for part in dn} 79 | 80 | try: 81 | random = secrets.token_hex(16) # generates 32 characters 82 | username = self.entityUsernameScheme.format(random=random, **dn) 83 | password = self.entityPasswordScheme.format(random=random, **dn) 84 | except KeyError as e: 85 | return None, f"DN is missing field {e}" 86 | 87 | try: 88 | result = self.client.service.certificateRequest( 89 | self.userData( 90 | username=username, 91 | password=password, 92 | clearPwd=False, 93 | subjectDN=subjectDN, 94 | caName=self.caName, 95 | subjectAltName=subjectAltName, 96 | email=email, 97 | status=10, # EndEntityConstants.STATUS_NEW = 10 98 | tokenType="USERGENERATED", # userDataVOWS.TOKEN_TYPE_USERGENERATED 99 | endEntityProfileName=self.endEntityProfileName, 100 | certificateProfileName=self.certificateProfileName, 101 | keyRecoverable=False, 102 | sendNotification=(email is not None), 103 | ), 104 | base64.b64encode(csr), 105 | 0, # CertificateHelper.CERT_REQ_TYPE_PKCS10 106 | None, 107 | "PKCS7WITHCHAIN", # CertificateHelper.RESPONSETYPE_PKCS7WITHCHAIN 108 | ) 109 | return base64.b64decode(result.data), None 110 | except zeep.exceptions.Fault as e: 111 | # observed these exception types: 112 | # - org.cesecore.certificates.certificate.CertificateCreateException 113 | # - org.ejbca.core.model.ra.raadmin.EndEntityProfileValidationException 114 | typestr, _, message = e.message.partition(":") 115 | return None, message 116 | -------------------------------------------------------------------------------- /serles/challenge.py: -------------------------------------------------------------------------------- 1 | import json 2 | import socket 3 | import requests 4 | import jwcrypto.jwk # fedora package: python3-jwcrypto.noarch 5 | import jwcrypto.jws 6 | 7 | from datetime import datetime, timezone 8 | 9 | from cryptography import x509 # python3-cryptography.x86_64 10 | from cryptography.hazmat.backends import default_backend as x509_backend 11 | from cryptography.hazmat.primitives import serialization 12 | 13 | from .utils import get_ptr, ip_in_ranges, normalize 14 | from .configloader import get_config 15 | from .models import * 16 | from .exceptions import ACMEError 17 | 18 | config = {} 19 | backend = None 20 | 21 | 22 | def init_config(): 23 | global config, backend 24 | config, backend = get_config() 25 | 26 | 27 | def verify_challenge(challenge): 28 | """ verify a challenge 29 | 30 | Args: 31 | challenge (Challenge): The challenge to verify. 32 | 33 | Returns: 34 | bool: True on success, False on error. 35 | 36 | Raises: 37 | ACMEError: Verification failed. 38 | """ 39 | error = None 40 | 41 | # check that the challenge hasn't expired yet: 42 | if challenge.authorization.expires < datetime.now(timezone.utc): 43 | challenge.status = ChallengeStatus.invalid 44 | challenge.authorization.status = AuthzStatus.expired 45 | challenge.authorization.order.status = OrderStatus.invalid 46 | db.session.commit() 47 | raise ACMEError("challenge expired", 400, "malformed") # better error code? 48 | 49 | if challenge.type == ChallengeTypes.http_01: 50 | error, info = http_challenge(challenge) 51 | else: 52 | challenge.status = ChallengeStatus.invalid 53 | db.session.commit() 54 | raise ACMEError("challenge type not supported", 501, "serverInternal") 55 | 56 | if error: 57 | # the challenge was not fulfilled, but hasn't expired yet either; we 58 | # may try again _only_after_ the client has sent a retry request 59 | # (RFC8555 §8.2) 60 | challenge.error = json.dumps(dict(type=f"urn:ietf:params:acme:error:{error}")) 61 | db.session.commit() 62 | raise ACMEError(info, 400, error) 63 | 64 | # if the challenge was sucessfully validated, propagate up: 65 | challenge.status = ChallengeStatus.valid 66 | challenge.validated = datetime.now(timezone.utc) 67 | # the authorization is valid if any challenge succeeded: 68 | challenge.authorization.status = AuthzStatus.valid 69 | # the order is only valid if _all_ authorizations are: 70 | for authz in challenge.authorization.order.authorizations: 71 | if authz.status != AuthzStatus.valid: 72 | break 73 | else: 74 | challenge.authorization.order.status = OrderStatus.ready 75 | # the challenge is now verified 76 | db.session.commit() 77 | 78 | 79 | def http_challenge(challenge): # RFC8555 §8.3 80 | """ verify a HTTP Challenge 81 | 82 | Args: 83 | challenge (Challenge): The HTTP challenge to verify. 84 | 85 | Returns: 86 | tuple(str,str): problem detail type of the error and textual 87 | description, or (None,None). 88 | """ 89 | if config["skip_challenge"]: 90 | return None, None 91 | 92 | host = challenge.authorization.identifier.value 93 | token = challenge.token 94 | prefix = ".well-known/acme-challenge" 95 | session = requests.Session() 96 | session.trust_env = False # bypass proxy 97 | 98 | # follow redirect-to-https, but ignore self-signed certs: 99 | session.verify = False 100 | requests.packages.urllib3.disable_warnings( 101 | requests.packages.urllib3.exceptions.InsecureRequestWarning 102 | ) 103 | 104 | # Setting stream=True lets us access the socket used to establish the 105 | # connection. We use this to get the IP address of the server we connect to 106 | # to verify it is in the range(s) of allowed addresses. Note that the 107 | # socket goes away once we read r.content or r.text. 108 | try: 109 | r = session.get(f"http://{host}/{prefix}/{token}", stream=True) 110 | except requests.ConnectionError as e: 111 | return "connection", str(e) # also catches dns and tls errors 112 | 113 | try: # this sometimes fails (sock is None) 114 | remote_ip, *_ = r.raw.connection.sock.getpeername() 115 | except AttributeError: 116 | sock = socket.fromfd(r.raw.fileno(), socket.AF_INET, socket.SOCK_STREAM) 117 | remote_ip, *_ = sock.getpeername() 118 | 119 | # additional checks that are useful in an enterprise setting, but not 120 | # required by spec: 121 | if config["allowedServerIpRanges"] and not ip_in_ranges( 122 | remote_ip, config["allowedServerIpRanges"] 123 | ): 124 | return "rejectedIdentifier", f"{remote_ip} not in allowed ranges" 125 | if config["excludeServerIpRanges"] and ip_in_ranges( 126 | remote_ip, config["excludeServerIpRanges"] 127 | ): 128 | return "rejectedIdentifier", f"{remote_ip} in excluded range" 129 | if config["verifyPTR"] and normalize(get_ptr(remote_ip)) != normalize(host): 130 | return "rejectedIdentifier", f"PTR does not match" 131 | 132 | thumbprint = jwcrypto.jwk.JWK.from_pem( 133 | challenge.authorization.order.account.jwk 134 | ).thumbprint() 135 | 136 | expect = f"{token}.{thumbprint}" 137 | if not r.ok or r.text != expect: 138 | return "incorrectResponse", f"expected {expect}, got {r.text}" 139 | 140 | return None, None # no error occurred :) 141 | 142 | 143 | def pkcs7_to_pem_chain(pkcs7_input): 144 | """ Converts a PKCS#7 cert chain to PEM format. 145 | 146 | Attempts to use python-cryptography 3.1 or falls back to using the 147 | openssl(1) tool. 148 | 149 | Args: 150 | pkcs7_input (bytes): the PKCS#7 chain as stored in the database. 151 | 152 | Returns: 153 | str: PEM encoded certificate chain as expected by ACME clients. 154 | """ 155 | from cryptography import __version__ as crypto_version 156 | 157 | v = [int(s) if s.isdigit() else -1 for s in crypto_version.split(".")] 158 | 159 | if v[0] > 3 or (v[0] == 3 and v[1] >= 1): # if cryptography 3.1 or higher: 160 | from cryptography.hazmat.primitives.serialization import pkcs7 161 | 162 | certs = serialization.pkcs7.load_der_pkcs7_certificates(pkcs7_input) 163 | return "\n".join( 164 | [ 165 | cert.public_bytes(serialization.Encoding.PEM).decode("ascii") 166 | for cert in certs 167 | ] 168 | ) 169 | else: 170 | from subprocess import Popen, PIPE, DEVNULL 171 | 172 | proc = Popen( 173 | ["openssl", "pkcs7", "-print_certs", "-inform", "DER"], 174 | stdin=PIPE, 175 | stdout=PIPE, 176 | stderr=DEVNULL, 177 | ) 178 | proc.stdin.write(pkcs7_input) 179 | proc.stdin.close() 180 | pem_cert = proc.stdout.read().decode("ascii") 181 | return "\n".join( 182 | [ 183 | l 184 | for l in pem_cert.splitlines() 185 | if not l.startswith("subject=") and not l.startswith("issuer=") 186 | ] 187 | ) 188 | 189 | 190 | def check_csr_and_return_cert(csr_der, order): 191 | """ validate CSR and pass to backend 192 | 193 | Checks that the CSR only contains domains from previously validated 194 | challenges and get a signed certificate from the backend. 195 | 196 | Args: 197 | csr_der (bytes): client's CSR in DER encoding 198 | order (Order): the order object that the CSR belongs to 199 | 200 | Returns: 201 | bytes: the signed certificate and chain in PKCS#7 format (DER encoded) 202 | 203 | Raises: 204 | ACMEError: CSR was rejected (by us) or the backend refused to sign it. 205 | """ 206 | csr = x509.load_der_x509_csr(csr_der, x509_backend()) 207 | try: 208 | alt_names = csr.extensions.get_extension_for_oid( 209 | x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME 210 | ).value.get_values_for_type(x509.DNSName) 211 | except: 212 | alt_names = [] 213 | try: 214 | common_name = csr.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)[ 215 | 0 216 | ].value 217 | except IndexError: 218 | # certbot does not set Subject Name, only SANs 219 | # https://github.com/certbot/certbot/issues/4922 220 | common_name = alt_names[0] 221 | 222 | if not common_name in alt_names: # chrome ignores CN, so write CN to SAN 223 | alt_names.insert(0, common_name) 224 | 225 | # since we pass the CN and SANs to the backend, make sure the client only 226 | # specified those that we verified before: 227 | order_identifiers = {ident.value for ident in order.identifiers} 228 | csr_identifiers = {*alt_names} # convert list to set 229 | if order_identifiers != csr_identifiers: 230 | raise ACMEError(f"{order_identifiers} != {csr_identifiers}", 400, "badCSR") 231 | 232 | csr_der = csr.public_bytes(serialization.Encoding.DER) 233 | email = order.account.contact 234 | subject_dn = csr.subject.rfc4514_string() 235 | if config["forceTemplateDN"] or not subject_dn: 236 | subject_dn = config["subjectNameTemplate"].format(SAN=alt_names, MAIL=email) 237 | 238 | certificate, error = backend.sign(csr_der, subject_dn, alt_names, email) 239 | 240 | if error: 241 | raise ACMEError(error, 400, "badCSR") 242 | 243 | return certificate 244 | -------------------------------------------------------------------------------- /serles/configloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import importlib 3 | import ipaddress 4 | from configparser import ConfigParser 5 | 6 | 7 | def get_config(): 8 | """ 9 | Reads the configuration from the environment variable or the default path. 10 | 11 | Returns: 12 | (dict, class): A tuple of ``config``, ``backend``. 13 | """ 14 | config, backend = load_config_and_backend( 15 | os.environ.get("CONFIG", "/etc/serles/config.ini") 16 | ) 17 | return config, backend 18 | 19 | 20 | class ConfigError(Exception): 21 | """ 22 | This exception is raised when an error occurred while reading the 23 | config. 24 | """ 25 | 26 | pass 27 | 28 | 29 | def load_config_and_backend(filename): 30 | """ 31 | Parses the config file given, or raises an exception. This is called 32 | directly on startup (as opposed to when a certificate request comes in) to 33 | alert the administrator to erros immediately. 34 | 35 | Args: 36 | filename: config file to load. 37 | 38 | Returns: 39 | (object, configparser.ConfigParser): A tuple containing the Backend 40 | class and the parsed config (dict-like) 41 | 42 | Raises: 43 | ConfigError: The config could not be loaded, is missing a required key 44 | or the specified Backend could not be loaded. 45 | """ 46 | config = {} 47 | backend = None 48 | 49 | cparser = ConfigParser() 50 | if not cparser.read(filename): 51 | raise ConfigError("unable to load config file") from None 52 | 53 | try: 54 | mod, _, cls = cparser["serles"]["backend"].partition(":") 55 | backendModule = importlib.import_module(mod, __name__) 56 | backend = getattr(backendModule, cls or "Backend")(cparser) 57 | assert hasattr(backend, "sign") 58 | except KeyError: 59 | raise ConfigError( 60 | "please define the backend class to use in [serles]backend=" 61 | ) from None 62 | except ModuleNotFoundError: 63 | raise ConfigError("the backend class could not be loaded") from None 64 | except AssertionError: 65 | raise ConfigError( 66 | "backend does not define a sign method (wrong class loaded?)" 67 | ) from None 68 | 69 | try: 70 | config["server_name"] = cparser["serles"]["server_name"] 71 | except KeyError: 72 | raise ConfigError("no [serles]server_name= configured") from None 73 | 74 | try: 75 | config["database"] = cparser["serles"]["database"] 76 | except KeyError: 77 | raise ConfigError("no [serles]database= configured") from None 78 | 79 | try: 80 | ranges = cparser["serles"]["allowedServerIpRanges"].splitlines() 81 | config["allowedServerIpRanges"] = [ 82 | ipaddress.ip_network(cidr) for cidr in ranges if cidr 83 | ] 84 | except KeyError: 85 | config["allowedServerIpRanges"] = None # if not defined, allow from everywhere. 86 | 87 | try: 88 | ranges = cparser["serles"]["excludeServerIpRanges"].splitlines() 89 | config["excludeServerIpRanges"] = [ 90 | ipaddress.ip_network(cidr) for cidr in ranges if cidr 91 | ] 92 | except KeyError: 93 | config["excludeServerIpRanges"] = None 94 | 95 | try: 96 | config["subjectNameTemplate"] = cparser["serles"]["subjectNameTemplate"] 97 | except KeyError: 98 | raise ConfigError("must define [serles]subjectNameTemplate=") from None 99 | 100 | try: 101 | config["forceTemplateDN"] = cparser["serles"].getboolean( 102 | "forceTemplateDN", fallback=False 103 | ) 104 | except ValueError: 105 | raise ConfigError( 106 | "[serles]forceTemplateDN= must be 'true' or 'false'" 107 | ) from None 108 | 109 | try: 110 | config["verifyPTR"] = cparser["serles"].getboolean("verifyPTR", fallback=False) 111 | except ValueError: 112 | raise ConfigError("[serles]verifyPTR= must be 'true' or 'false'") from None 113 | 114 | try: 115 | config["skip_challenge"] = cparser["serles"].getboolean("skip_challenge", fallback=False) 116 | except ValueError: 117 | raise ConfigError("[serles]skip_challenge= must be 'true' or 'false'") from None 118 | 119 | return config, backend 120 | -------------------------------------------------------------------------------- /serles/exceptions.py: -------------------------------------------------------------------------------- 1 | class ACMEError(Exception): 2 | """ 3 | Raise this exception on invalid API usage. The exception handler will 4 | return an Error Document to the client. 5 | 6 | Args: 7 | message (str): returned to client as the "detail" field. 8 | status (int): HTTP status code for the response. 9 | error_type (str): type token from the ACME namespace. 10 | """ 11 | 12 | def __init__(self, message, status, error_type): 13 | super().__init__(message) 14 | self.status = status 15 | self.error_type = error_type 16 | -------------------------------------------------------------------------------- /serles/flask_handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import jwcrypto.jwk # fedora package: python3-jwcrypto.noarch 3 | import jwcrypto.jws 4 | 5 | from flask import g, request, jsonify, make_response, current_app 6 | from werkzeug.exceptions import HTTPException 7 | 8 | from .utils import base64d 9 | from .views import * # Note: import views before models! 10 | from .models import * 11 | from .exceptions import ACMEError 12 | 13 | 14 | def parse_jws(): # RFC8555 §6.2 15 | """ 16 | Verify that the signature is as specified by the RFC, and make the payload 17 | available to all POST views using Flask's "g" object. Note that we aren't 18 | fully checking every detail, just the security relevant ones. 19 | Note also that we don't support key rollover (as required by spec). 20 | 21 | This function is registered as a before_request handler and augments the 22 | ``g`` object with the following attributes: 23 | - ``g.payload``: the actually interesting request data, JSON-decoded 24 | - ``g.kid``: the JWK key id (in our case a uuid) of the user, if known 25 | - ``g.jwk``: the JWK public key, when a user tries to register 26 | 27 | Raises: 28 | ACMEError: The request was not understood or not authorized. 29 | """ 30 | 31 | if request.method != "POST": 32 | return 33 | 34 | if request.mimetype != "application/jose+json": 35 | raise ACMEError("expected application/jose+json", 405, "malformed") 36 | 37 | if not "protected" in request.json: 38 | raise ACMEError("no 'protected' field in request", 400, "malformed") 39 | 40 | protected = json.loads(base64d(request.json.get("protected"))) 41 | if "kid" in protected: # existing user 42 | (_, _, kid) = protected["kid"].rpartition("/") 43 | account = Account.query.filter_by(id=kid).first() 44 | if not account: 45 | raise ACMEError("unknown key id", 400, "accountDoesNotExist") 46 | key = jwcrypto.jwk.JWK.from_pem(account.jwk) 47 | g.kid = kid 48 | elif "jwk" in protected: # new user trying to access /newAccount 49 | jwk = protected["jwk"] 50 | key = jwcrypto.jwk.JWK(**jwk) 51 | g.jwk = jwk 52 | else: 53 | raise ACMEError("no public key or key id", 400, "unauthorized") 54 | 55 | jws = jwcrypto.jws.JWS() 56 | assert "none" not in jws.allowed_algs 57 | 58 | try: 59 | jws.deserialize(request.data, key) 60 | except jwcrypto.jws.InvalidJWSSignature as e: 61 | raise ACMEError("signed with invalid or wrong key", 403, "unauthorized") 62 | 63 | if not "nonce" in jws.jose_header or not Nonces.check(jws.jose_header["nonce"]): 64 | raise ACMEError("nonce invalid", 400, "badNonce") 65 | if not "url" in jws.jose_header or jws.jose_header["url"] != request.url: 66 | raise ACMEError("url doesn't match", 400, "unauthorized") 67 | 68 | # make available to view function: 69 | g.payload = json.loads(jws.payload) if jws.payload else {} 70 | 71 | 72 | def inject_nonce(response): 73 | response.headers.extend({"Replay-Nonce": Nonces.new()}) 74 | return response 75 | 76 | 77 | def index_header(response): # RFC 8555 §7.1 78 | response.headers.extend( 79 | {"Link": f"<{api.url_for(Directory, _external=True)}>;rel=index"} 80 | ) 81 | return response 82 | 83 | 84 | def exception_handler(error): 85 | """ 86 | This function is called by Flask when an exception is raised. We use the 87 | our ACMEError to return errors for failed API requests. Other exceptions 88 | are caught and transformed into an Internal Server Error and we log the 89 | exception for later review. 90 | Responses are in Problem Details format (RFC7807), albeit only with minimal 91 | content. 92 | """ 93 | if isinstance(error, ACMEError): 94 | status = error.status 95 | error_type = error.error_type 96 | error_details = str(error) 97 | elif isinstance(error, HTTPException): 98 | status = error.code 99 | error_type = "malformed" # there isn't really anything well-fitting. 100 | error_details = error.description 101 | else: 102 | status = 500 103 | error_type = "serverInternal" 104 | error_details = str(error) 105 | 106 | current_app.logger.error(f"{error_type} ({status}): {error_details}") 107 | 108 | if status >= 500: 109 | import traceback 110 | 111 | current_app.logger.error(traceback.format_exc()) 112 | 113 | # wrapping into make_response, or flask-restful will overwrite content-type: 114 | return make_response( 115 | jsonify( 116 | { 117 | "type": f"urn:ietf:params:acme:error:{error_type}", 118 | "detail": error_details, 119 | } 120 | ), 121 | status, 122 | {"Content-Type": "application/problem+json"}, 123 | ) 124 | -------------------------------------------------------------------------------- /serles/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import json 3 | import secrets 4 | 5 | from enum import Enum 6 | from datetime import datetime, timezone, timedelta 7 | 8 | from flask_sqlalchemy import SQLAlchemy # python3-flask-sqlalchemy.noarch 9 | from sqlalchemy.ext.hybrid import hybrid_property 10 | 11 | from . import views 12 | 13 | db = SQLAlchemy(session_options={"expire_on_commit": False}) 14 | 15 | # Note: .serialized provides a dict() that gets jsonified by flask_restful. 16 | class UTCDateTime(db.TypeDecorator): 17 | """ 18 | SQLite stores datetimes without TZ info, and SQLAlchemy then returns 19 | TZ-naive datetimes. This breaks calculating timedeltas. So this wrapper 20 | converts incoming timestamps to UTC before storing and adds the TZ (utc) 21 | back on retrieval. 22 | """ 23 | 24 | impl = db.DateTime 25 | 26 | cache_ok = False 27 | 28 | def process_bind_param(self, val, _): 29 | return val.astimezone(timezone.utc) if val else None 30 | 31 | def process_result_value(self, val, _): 32 | return val.replace(tzinfo=timezone.utc) if val else None 33 | 34 | 35 | class OrderStatus(Enum): 36 | """ `RFC 8555 § 7.1.6 (Fig.3) `_ """ 37 | 38 | pending = "pending" 39 | ready = "ready" 40 | processing = "processing" 41 | valid = "valid" 42 | invalid = "invalid" 43 | 44 | 45 | class Order(db.Model): # RFC8555 §7.1.3 46 | """ 47 | In ACME lingo, an Order identifies a client's request for a certificate. It 48 | keeps a list of Identifiers (domain names) requested from the client, a 49 | list of Authorizations (see below), and (once we decided to issue one) the 50 | certificate. 51 | """ 52 | 53 | id = db.Column(db.String(45), primary_key=True, default=lambda: uuid.uuid4().urn) 54 | status = db.Column(db.Enum(OrderStatus), default=OrderStatus.pending) 55 | expires = db.Column( 56 | UTCDateTime, default=lambda: datetime.now(timezone.utc) + timedelta(days=7) 57 | ) 58 | identifiers = db.relationship( 59 | "Identifier", backref=db.backref("order"), cascade="all, delete" 60 | ) 61 | notBefore = db.Column(db.DateTime) 62 | notAfter = db.Column(db.DateTime) 63 | error = db.Column(db.Text) 64 | authorizations = db.relationship( 65 | "Authorization", backref=db.backref("order"), cascade="all, delete" 66 | ) 67 | certificate = db.relationship( 68 | "Certificate", backref=db.backref("order"), cascade="all, delete", uselist=False 69 | ) 70 | account_id = db.Column(db.String(45), db.ForeignKey("account.id")) 71 | 72 | @hybrid_property 73 | def finalize(self): 74 | return views.api.url_for(views.OrderFinalize, orderid=self.id, _external=True) 75 | 76 | @property 77 | def serialized(self): 78 | # fmt: off 79 | return {k:v for k,v in { 80 | "status": self.status.value, # required 81 | "expires": self.expires.isoformat() if self.expires else None, # required 82 | "identifiers": [ident.serialized for ident in self.identifiers], # required 83 | "notBefore": self.notBefore.isoformat() if self.notBefore else None, # optional 84 | "notAfter": self.notAfter.isoformat() if self.notAfter else None, # optional 85 | "error": json.loads(self.error) if self.error else None, # optional 86 | "authorizations": [authz.url for authz in self.authorizations], # required 87 | "finalize": self.finalize, # required 88 | "certificate": self.certificate.url if self.certificate else None, # optional 89 | }.items() if v is not None} 90 | # fmt: on 91 | 92 | 93 | class IdentifierTypes(Enum): 94 | dns = "dns" 95 | 96 | 97 | class Identifier(db.Model): 98 | """ 99 | An Identifier is essentially a domain name and some metadata. 100 | """ 101 | 102 | id = db.Column(db.String(45), primary_key=True, default=lambda: uuid.uuid4().urn) 103 | type = db.Column(db.Enum(IdentifierTypes), default=IdentifierTypes.dns) 104 | value = db.Column(db.Text, nullable=False) 105 | order_id = db.Column(db.String(45), db.ForeignKey("order.id")) 106 | authz_id = db.Column(db.String(45), db.ForeignKey("authorization.id")) 107 | 108 | @property 109 | def serialized(self): 110 | # fmt: off 111 | return { 112 | "type": self.type.value, 113 | "value": self.value, 114 | } 115 | # fmt: on 116 | 117 | 118 | class AuthzStatus(Enum): 119 | """ `RFC 8555 § 7.1.6 (Fig.2) `_ """ 120 | 121 | pending = "pending" 122 | valid = "valid" 123 | invalid = "invalid" 124 | deactivated = "deactivated" 125 | expired = "expired" 126 | revoked = "revoked" 127 | 128 | 129 | class Authorization(db.Model): # RFC8555 §7.1.4 130 | """ 131 | For each Identifier (domain name) the client requested in an Order, there 132 | is a Authorization. To obtain a certificate, the client must satisfy all of 133 | them. To satisfy an Authorization, the client can solve any one of the 134 | Challenges within (i.e., only 1 Challenge per Authorization is required). 135 | """ 136 | 137 | id = db.Column(db.String(45), primary_key=True, default=lambda: uuid.uuid4().urn) 138 | identifier = db.relationship( 139 | "Identifier", 140 | backref=db.backref("authorization"), 141 | cascade="all, delete", 142 | uselist=False, 143 | ) 144 | status = db.Column(db.Enum(AuthzStatus), default=AuthzStatus.pending) 145 | expires = db.Column( 146 | UTCDateTime, default=lambda: datetime.now(timezone.utc) + timedelta(days=7) 147 | ) 148 | challenges = db.relationship( 149 | "Challenge", backref=db.backref("authorization"), cascade="all, delete" 150 | ) 151 | wildcard = db.Column(db.Boolean, default=False) 152 | order_id = db.Column(db.String(45), db.ForeignKey("order.id")) 153 | 154 | @hybrid_property 155 | def url(self): 156 | return views.api.url_for( 157 | views.AuthorizationMain, authid=self.id, _external=True 158 | ) 159 | 160 | @property 161 | def serialized(self): 162 | # fmt: off 163 | return {k:v for k,v in { 164 | "identifier": self.identifier.serialized, # required 165 | "status": self.status.value, # required 166 | "expires": self.expires.isoformat() if self.expires else None, # required 167 | "challenges": [chall.serialized for chall in self.challenges], # required 168 | "wildcard": self.wildcard, # optional 169 | }.items() if v is not None} 170 | # fmt: on 171 | 172 | 173 | class ChallengeTypes(Enum): 174 | http_01 = "http-01" 175 | dns_01 = "dns-01" 176 | 177 | 178 | class ChallengeStatus(Enum): 179 | """ `RFC 8555 § 7.1.6 (Fig.1) `_ """ 180 | 181 | pending = "pending" 182 | processing = "processing" 183 | valid = "valid" 184 | invalid = "invalid" 185 | 186 | 187 | class Challenge(db.Model): # RFC8555 §7.1.5 188 | """ 189 | A completed Challenge satisfies an Authorization. We support the HTTP-01 190 | challenge type, which requires the client to temporarily serve a short text 191 | string on a location we decide. 192 | """ 193 | 194 | id = db.Column(db.String(45), primary_key=True, default=lambda: uuid.uuid4().urn) 195 | type = db.Column(db.Enum(ChallengeTypes), nullable=False) 196 | status = db.Column(db.Enum(ChallengeStatus), default=ChallengeStatus.pending) 197 | validated = db.Column(UTCDateTime) 198 | error = db.Column(db.Text) 199 | # http-01, dns-01 200 | token = db.Column( 201 | db.Text, nullable=False, default=lambda: secrets.token_urlsafe(32) 202 | ) 203 | 204 | @hybrid_property 205 | def url(self): 206 | return views.api.url_for(views.ChallengeMain, challid=self.id, _external=True) 207 | 208 | authz_id = db.Column(db.String(45), db.ForeignKey("authorization.id")) 209 | 210 | @property 211 | def serialized(self): 212 | # fmt: off 213 | return {k:v for k,v in { 214 | "type": self.type.value, # required 215 | "url": self.url, # required 216 | "status": self.status.value, # required 217 | "validated": self.validated.isoformat() \ 218 | if self.status == ChallengeStatus.valid else None, # required if valid 219 | "error": json.loads(self.error) if self.error else None, # optional 220 | "token": self.token, 221 | }.items() if v is not None} 222 | # fmt: on 223 | 224 | 225 | class Certificate(db.Model): 226 | """ 227 | Pretty much what it says on the tin. Stored in the database for the short 228 | time between the client requesting finalization of their order and them 229 | fetching the cert from us. 230 | """ 231 | 232 | id = db.Column(db.String(45), primary_key=True, default=lambda: uuid.uuid4().urn) 233 | certificate = db.Column(db.LargeBinary) 234 | 235 | @hybrid_property 236 | def url(self): 237 | return views.api.url_for(views.CertificateMain, certid=self.id, _external=True) 238 | 239 | order_id = db.Column(db.String(45), db.ForeignKey("order.id")) 240 | 241 | @property 242 | def serialized(self): 243 | # fmt: off 244 | return self.certificate 245 | # fmt: on 246 | 247 | 248 | class AccountStatus(Enum): 249 | """ `RFC 8555 § 7.1.6 `_ """ 250 | 251 | valid = "valid" 252 | deactivated = "deactivated" 253 | revoked = "revoked" 254 | 255 | 256 | class Account(db.Model): # RFC8555 §7.1.2 257 | """ 258 | To avoid having to send the large public key for each request, a client 259 | registers an Account and identifies themselves using the key id. On 260 | letsencrypt, accounts persist over a long time, and some clients will try 261 | to keep using it. Certbot is especially bad at this, as it fails when the 262 | account it expects doesn't exist. This object also stores an optional 263 | contact email address, which we pass to the backend (e.g. for notifications 264 | regarding certificate expiry). 265 | """ 266 | 267 | id = db.Column(db.String(45), primary_key=True, default=lambda: uuid.uuid4().urn) 268 | jwk = db.Column(db.LargeBinary) # PEM encoded public key 269 | status = db.Column(db.Enum(AccountStatus), default=AccountStatus.valid) 270 | orders = db.relationship( 271 | "Order", backref=db.backref("account"), cascade="all, delete" 272 | ) 273 | contact = db.Column(db.Text) 274 | # termsOfServiceAgreed = db.Column(db.Boolean) 275 | # externalAccountBinding # object 276 | @hybrid_property 277 | def url(self): 278 | return views.api.url_for(views.AccountMain, kid=self.id, _external=True) 279 | 280 | @hybrid_property 281 | def orders_url(self): 282 | return views.api.url_for(views.AccountOrders, kid=self.id, _external=True) 283 | 284 | @property 285 | def serialized(self): 286 | # fmt: off 287 | return {k:v for k,v in { 288 | "status": self.status.value, # required 289 | "contact": [self.contact], # we only support 1 email 290 | "orders": self.orders_url, # required 291 | #termsOfServiceAgreed # optional 292 | #externalAccountBinding # optional 293 | }.items() if v is not None} 294 | # fmt: on 295 | 296 | 297 | class Nonces(db.Model): 298 | """ 299 | To avoid replay attacks, each HTTP POST request must come with a nonce we 300 | issued. 301 | """ 302 | 303 | value = db.Column( 304 | db.String(22), primary_key=True, default=lambda: secrets.token_urlsafe(16) 305 | ) 306 | expires = db.Column( 307 | UTCDateTime, default=lambda: datetime.now(timezone.utc) + timedelta(hours=1) 308 | ) 309 | 310 | @classmethod 311 | def new(cls): 312 | nonce = cls() 313 | db.session.add(nonce) 314 | db.session.commit() 315 | return nonce.value 316 | 317 | @classmethod 318 | def check(cls, value): 319 | """ returns True iff the nonce is valid (not yet used) """ 320 | nonce = cls.query.filter(cls.value == value) 321 | if nonce.count(): 322 | nonce.delete() 323 | db.session.commit() 324 | return True 325 | return False 326 | 327 | @classmethod 328 | def purge_expired(cls): 329 | cls.query.filter(cls.expires < datetime.now(timezone.utc)).delete() 330 | db.session.commit() 331 | -------------------------------------------------------------------------------- /serles/templates/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | HTools CA 8 | 9 | 15 | 16 | 17 |
20 |
21 |
22 | HTools 23 |

24 | HTools Certificate Authority
25 |

just kidding, kinda.

26 |

27 | 31 | 37 | 40 | 41 | 42 |
43 | 44 |
45 |
46 |

47 | This is an 48 | ACME 55 | server, similar to what 56 | LetsEncrypt 63 | runs. 64 |

65 |

66 | But, instead of signing your certificates being signed by a 67 | trusted CA, it creates a self-signed certificate. This can be used 68 | with 69 | DANE 76 | to secure Handshake websites. 77 |

78 |
79 | 80 |
83 | # Have a web server serving HTTP already: 84 | HTools Blog Post
91 | # Install certbot (or any client) normally: 92 | https://certbot.eff.org/
99 | # Get a certificate and install it like:

100 | sudo certbot --nginx -d your_tld.or_sld \
101 |     --server https://acme.htools.work/directory 102 | --reuse-key 103 |

104 | # Finally, set the TLSA record from 105 | https://acme.htools.work/tlsa 112 |
113 | 114 |
115 |
116 |

Why does this even exist?

117 |

118 | Because you can use any ACME client (like 119 | certbot) and take advantage of existing plugins for all kinds of web 126 | servers. Also, there's no need to bother with OpenSSL and TLSA. 127 |

128 |
129 |
130 |

What if the CA is compromised?

131 |

132 | Nothing happens. A new CA key is generated for every issued 133 | certificate and then destroyed immediately. And with DANE, it 134 | really wouldn't matter anyway. 135 |

136 |
137 |
138 |

I want the TLSA email.

139 |

140 | To receive emails containing TLSA records when certificates are 141 | issued, add a 142 | +email 146 | to the end of your email address like: 147 | foobar+email@gmail.com. 151 |

152 |
153 |
154 |

Help!

155 |

156 | Feel free to join Handshake's 157 | Telegram 164 | or 165 | Discord 172 | groups and we'll do our best to figure out how to fix. 173 |

174 |
175 |
176 |
177 |
178 | 179 |
182 |

183 | Made with ♥️ by 184 | Rithvik Vibhu 185 |

186 |
187 |
188 | 189 | 190 | -------------------------------------------------------------------------------- /serles/templates/tlsa.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TLSA | HTools CA 8 | 9 | 15 | 16 | 17 |
20 |
21 |
22 | HTools 23 |

24 | HTools Certificate Authority
25 |

just kidding, kinda.

26 |

27 | 31 | 37 | 40 | 41 | 42 |
43 | 44 |
45 |
46 |
50 | 58 | 59 | 65 |
66 |
67 | 68 |
69 |

TLSA Record to add:

70 |
Enter domain above.
71 |
72 | 73 | 108 |
109 |
110 | 111 |
114 |

115 | Made with ♥️ by 116 | Rithvik Vibhu 117 |

118 |
119 |
120 | 121 | 122 | -------------------------------------------------------------------------------- /serles/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import subprocess 3 | import ipaddress 4 | import dns.resolver # fedora package: python3-dns.noarch 5 | import dns.reversename 6 | from threading import Timer 7 | 8 | 9 | def background_job(interval): 10 | """ executes the decorated function in an interval 11 | 12 | A very simple "cron" replacement: decorate a function to call it in its own 13 | thread in a given interval. 14 | 15 | Args: 16 | interval (int): number of seconds between executions. 17 | """ 18 | 19 | def wrapper(f): 20 | f() 21 | t = Timer(interval, wrapper, args=(f,)) 22 | t.setDaemon(True) 23 | t.start() 24 | 25 | return wrapper 26 | 27 | 28 | def base64d(s): 29 | """ padding-ignoring base64-decoder 30 | 31 | ACME uses url-safe base64, but does not add padding. Python's base64 lib 32 | throws an exception on wrong padding, so we add it for it. 33 | 34 | Args: 35 | s (str): input to decode. 36 | 37 | Returns: 38 | bytes: decoded input. 39 | """ 40 | 41 | return base64.urlsafe_b64decode(s + "===") 42 | 43 | 44 | def query(qname, rdtype): 45 | """ Query DNS records without raising an exception. 46 | 47 | Args: 48 | qname (str): query name. 49 | rdtype (str): query type. 50 | 51 | Returns: 52 | list: results, or the empty list on error. 53 | """ 54 | try: 55 | if hasattr(dns.resolver, "resolve"): # dnspython 2.x 56 | return dns.resolver.resolve(qname, rdtype, search=True) 57 | else: # dnspython 1.x 58 | return dns.resolver.query(qname, rdtype) 59 | except dns.resolver.NXDOMAIN: 60 | return [] 61 | 62 | 63 | def get_ptr(ipaddr): 64 | """ resolve an IP address to a domain name. 65 | 66 | Args: 67 | ipaddr (str): query. 68 | 69 | Returns: 70 | str: FQDN, or None. 71 | 72 | """ 73 | ptr = query(dns.reversename.from_address(ipaddr), "PTR") 74 | return str(ptr[0]) if ptr else None 75 | 76 | 77 | def ip_in_ranges(ipaddr, ranges): 78 | """ check whether the given IP is in any of the given subnets. 79 | 80 | Args: 81 | ipaddr (str): IP to check. 82 | ranges (list): list of ipaddress.IPv4Network or ipaddress.IPv6Network. 83 | 84 | Returns: 85 | bool: True if it is, False if it isn't. 86 | """ 87 | for ipRange in ranges: 88 | if ipaddress.ip_address(ipaddr) in ipRange: 89 | return True 90 | return False 91 | 92 | 93 | def normalize(domain): 94 | """ don't differentiate between FQDN and PartiallyQDN, ignore case 95 | 96 | Args: 97 | domain (str): domain name to normalize. 98 | 99 | Returns: 100 | str: normalized domain name. 101 | """ 102 | return domain.rstrip(".").lower() if domain else None 103 | 104 | 105 | def get_tlsa_remote_host(domain): 106 | ip = str(query(domain, 'A')[0]) 107 | p_sclient = subprocess.run(('openssl', 's_client', '-connect', ip+':443', '-servername', domain), input='Q'.encode('ascii'), check=True, capture_output=True, timeout=10) 108 | p_x509 = subprocess.run(('openssl', 'x509', '-pubkey', '-noout', '-in', '/dev/stdin'), input=p_sclient.stdout, capture_output=True, timeout=5) 109 | p_pkey = subprocess.run(('openssl', 'pkey', '-pubin', '-outform', 'der'), input=p_x509.stdout, capture_output=True, timeout=5) 110 | p_dgst = subprocess.run(('openssl', 'dgst', '-sha256', '-binary'), input=p_pkey.stdout, capture_output=True, timeout=5) 111 | p_xxd = subprocess.run(('xxd', '-p', '-u', '-c', '32'), input=p_dgst.stdout, capture_output=True, timeout=5) 112 | 113 | output = p_xxd.stdout.decode('utf-8').strip() 114 | return '3 1 1 ' + output 115 | -------------------------------------------------------------------------------- /serles/views.py: -------------------------------------------------------------------------------- 1 | import jwcrypto.jwk # fedora package: python3-jwcrypto.noarch 2 | import jwcrypto.jws 3 | 4 | from flask import g, make_response 5 | from flask_restful import Resource, Api 6 | 7 | from .utils import base64d 8 | from .models import * 9 | from .challenge import ( 10 | init_config as c_init_config, 11 | verify_challenge, 12 | pkcs7_to_pem_chain, 13 | check_csr_and_return_cert, 14 | ) 15 | from .exceptions import ACMEError 16 | 17 | api = Api() 18 | 19 | 20 | def init_config(): 21 | c_init_config() 22 | 23 | 24 | @api.resource("/directory") 25 | class Directory(Resource): 26 | def get(self): 27 | """ 28 | Displays the URLs for accessing certain functions, and some metadata. 29 | """ 30 | return { 31 | "newNonce": api.url_for(NewNonce, _external=True), 32 | "newAccount": api.url_for(NewAccount, _external=True), 33 | "newOrder": api.url_for(NewOrder, _external=True), 34 | # "newAuthz": MUST be absent if pre-authorization not supported 35 | # "revokeCert": not offered 36 | # optional: meta:{termsOfService"",website"",caaIdentities[""],externalAccountRequired?} 37 | "meta": { 38 | "website": "https://acme.htools.work", 39 | } 40 | } 41 | 42 | 43 | @api.resource("/newNonce") # RFC8555 §7.2 44 | class NewNonce(Resource): 45 | """ Lets the client fetch a nonce, if they ran out of 'em. """ 46 | 47 | # Note: the replay-nonce header is injected in a @after_request handler. 48 | def head(self): 49 | return "", 200, {"Cache-Control": "no-store"} 50 | 51 | def get(self): 52 | return "", 204, {"Cache-Control": "no-store"} 53 | 54 | 55 | @api.resource("/newAccount") # RFC8555 §7.3 56 | class NewAccount(Resource): 57 | def post(self): 58 | """ 59 | Request a new Account or get the Key ID associated with a JSON Web Key. 60 | """ 61 | contact = g.payload.get("contact", []) 62 | contact = contact[0] if len(contact) > 0 else None # only 1 email! 63 | if contact and not contact.startswith("mailto:"): 64 | raise ACMEError("only (one) email supported", 400, "unsupportedContact") 65 | if contact: 66 | contact = contact.replace("mailto:", "") 67 | termsOfServiceAgreed = g.payload.get("termsOfServiceAgreed", False) 68 | onlyReturnExisting = g.payload.get("onlyReturnExisting", False) 69 | 70 | # Note: we don't require clients to accept our terms of service. 71 | 72 | # To store the key in the database, and to compare a new key with 73 | # existing ones, we need to serialize it in a stable way. For this, we 74 | # arbitrarily chose the PEM format, as jwcrypto has support for it. 75 | jwk_pem = jwcrypto.jwk.JWK(**g.jwk).export_to_pem() 76 | 77 | account = Account.query.filter_by(jwk=jwk_pem).first() 78 | if account: # this jwk is already registered; RFC8555 §7.3.1 79 | # Note: we don't handle account key rollover, since accounts aren't 80 | # persistent (§7.3.5/.6). 81 | preexisting = True 82 | elif onlyReturnExisting: 83 | raise ACMEError("", 400, "accountDoesNotExist") 84 | else: # At this point, the user has no account, but wants one 85 | account = Account(jwk=jwk_pem, contact=contact) 86 | db.session.add(account) 87 | db.session.commit() # note: accessing `account` after the commit requires setting expire_on_commit=False 88 | preexisting = False 89 | 90 | return ( 91 | account.serialized, 92 | 200 if preexisting else 201, 93 | {"Location": account.url}, 94 | ) 95 | 96 | 97 | @api.resource("/newOrder") # RFC8555 §7.4 98 | class NewOrder(Resource): 99 | def post(self): 100 | """ 101 | Submit a new Order. The request will include a list of Identifers 102 | (domain names) the client wants on the certificate. 103 | """ 104 | notBefore = g.payload.get("notBefore") # optional, we ignore it for now 105 | notAfter = g.payload.get("notAfter") # optional, we ignore it for now 106 | identifiers = g.payload.get("identifiers") 107 | if not identifiers: 108 | raise ACMEError("no identifiers", 400, "malformed") 109 | 110 | # we check all the identifiers (i.e. domain names the client wants a 111 | # certificate for) and add them to the Order we store. If any 112 | # identifiers are not accepted, the whole order will be aborted. 113 | requested_identifiers = [] 114 | required_authorizations = [] 115 | for identifier in identifiers: 116 | if not "type" in identifier or not "value" in identifier: 117 | raise ACMEError("identifier not valid", 400, "malformed") 118 | type_ = identifier.get("type") 119 | value = identifier.get("value") 120 | if type_ != "dns": 121 | raise ACMEError( 122 | "can only do 'dns' type identifiers", 400, "rejectedIdentifier" 123 | ) 124 | 125 | identifier = Identifier(type=IdentifierTypes(type_), value=value) 126 | db.session.add(identifier) 127 | challenges = [Challenge(type=ChallengeTypes.http_01)] 128 | for c in challenges: 129 | db.session.add(c) 130 | authz = Authorization(identifier=identifier, challenges=challenges) 131 | db.session.add(authz) 132 | requested_identifiers.append(identifier) 133 | required_authorizations.append(authz) 134 | 135 | account = Account.query.filter_by(id=g.kid).first() 136 | if not account: 137 | raise ACMEError("", 400, "accountDoesNotExist") 138 | order = Order( 139 | account=account, 140 | identifiers=requested_identifiers, 141 | authorizations=required_authorizations, 142 | notBefore=notBefore, 143 | notAfter=notAfter, 144 | ) 145 | 146 | db.session.add(order) 147 | db.session.commit() # note: accessing `order` after the commit requires setting expire_on_commit=False 148 | return ( 149 | order.serialized, 150 | 201, 151 | {"Location": api.url_for(OrderMain, orderid=order.id, _external=True)}, 152 | ) 153 | 154 | 155 | @api.resource("/newAuthz") # RFC8555 §7.4.1 (Pre-authorization, not offered) 156 | class NewAuthz(Resource): 157 | "not offered." 158 | pass 159 | 160 | 161 | @api.resource("/revokeCert") # RFC8555 §7.6 (Certificate Revocation, not offered) 162 | class RevokeCert(Resource): 163 | "not offered." 164 | pass 165 | 166 | 167 | @api.resource("/account/") # RFC8555 §7.3.2 (Account Management) 168 | class AccountMain(Resource): 169 | def post(self, kid): 170 | """ 171 | View or update the specified Account object. 172 | 173 | Args: 174 | kid: JSON Web Key ID that identifies the account 175 | 176 | Returns: 177 | JSON-serialized Account object (post-update). 178 | """ 179 | if kid != g.kid: 180 | raise ACMEError(f"{kid}, {g.kid}Unexpected Account ID", 403, "unauthorized") 181 | account = Account.query.filter_by(id=kid).first() 182 | if not account: 183 | raise ACMEError("", 400, "accountDoesNotExist") 184 | 185 | # this endpoint is also used to update the account information. E.g, 186 | # acme_tiny uses it to set (even for new accounts) the contact email. 187 | contact = g.payload.get("contact", None) 188 | if contact is not None: 189 | contact = contact[0] if len(contact) > 0 else None # only 1 email! 190 | if contact and not contact.startswith("mailto:"): 191 | raise ACMEError("only (one) email supported", 400, "unsupportedContact") 192 | if contact: 193 | contact = contact.replace("mailto:", "") 194 | account.contact = contact 195 | db.session.commit() # note: accessing `account` after the commit requires setting expire_on_commit=False 196 | 197 | return account.serialized 198 | 199 | 200 | # Note: since we send the orders list with all account objects anyways, there's 201 | # no need for a separate route implementation. 202 | @api.resource("/account//orders") # RFC8555 §7.1.2.1 (Orders List) 203 | class AccountOrders(AccountMain): 204 | "see `AccountMain`." 205 | pass 206 | 207 | 208 | @api.resource("/order/") 209 | class OrderMain(Resource): 210 | def post(self, orderid): 211 | """ 212 | View the specified Order object. 213 | Args: 214 | orderid: 215 | 216 | Returns: 217 | JSON-serialized Order object. 218 | """ 219 | order = Order.query.filter_by(id=orderid).first() 220 | if not order: 221 | raise ACMEError("Order does not exist", 404, "malformed") 222 | if not order.account_id == g.kid: 223 | raise ACMEError("Unexpected Account ID", 403, "unauthorized") 224 | return order.serialized 225 | 226 | 227 | @api.resource("/order//finalize") # RFC8555 page 47 228 | class OrderFinalize(Resource): 229 | def post(self, orderid): 230 | """ 231 | Upload CSR and (if order is ready) start issuance process. 232 | 233 | Returns: 234 | JSON-serialized Order object, now including a certificate id. 235 | """ 236 | csr = base64d(g.payload.get("csr")) 237 | order = Order.query.filter_by(id=orderid).first() 238 | if not order: 239 | raise ACMEError("Order does not exist", 404, "malformed") 240 | if not order.account_id == g.kid: 241 | raise ACMEError("Unexpected Account ID", 403, "unauthorized") 242 | if order.status != OrderStatus.ready: 243 | raise ACMEError("", 403, "orderNotReady") 244 | 245 | certificate = check_csr_and_return_cert(csr, order) 246 | 247 | cert = Certificate(certificate=certificate) 248 | db.session.add(cert) 249 | order.status = OrderStatus.valid 250 | order.certificate = cert 251 | db.session.commit() # note: accessing `cert` (as we do indirectly from order.serialized) after the commit requires setting expire_on_commit=False 252 | 253 | return order.serialized 254 | 255 | 256 | @api.resource("/authorization/") # RFC8555 §7.5 257 | class AuthorizationMain(Resource): 258 | def post(self, authid): 259 | """ 260 | View the specified Authorization object that contains challenges. 261 | 262 | Args: 263 | authid: 264 | 265 | Returns: 266 | JSON-serialized Authorization object. 267 | """ 268 | authz = Authorization.query.filter_by(id=authid).first() 269 | if not authz: 270 | raise ACMEError("Authorization does not exist", 404, "malformed") 271 | if not authz.order.account_id == g.kid: 272 | raise ACMEError("Unexpected Account ID", 403, "unauthorized") 273 | return authz.serialized 274 | 275 | 276 | @api.resource("/challenge/") # RFC8555 §7.5.1 277 | class ChallengeMain(Resource): 278 | """ 279 | Once the client calls this endpoint, we can start verifying the challenge. 280 | """ 281 | 282 | def post(self, challid): 283 | challenge = Challenge.query.filter_by(id=challid).first() 284 | if not challenge: 285 | raise ACMEError("Challenge does not exist", 404, "malformed") 286 | if not challenge.authorization.order.account_id == g.kid: 287 | raise ACMEError("Unexpected Account ID", 403, "unauthorized") 288 | challenge.status = ChallengeStatus.processing 289 | 290 | verify_challenge(challenge) # sets challenge.status, raises on error 291 | 292 | authid = challenge.authz_id 293 | return ( 294 | challenge.serialized, 295 | 200, 296 | {"Link": f"<{api.url_for(AuthorizationMain, authid=authid, _external=True)}>;rel=up"}, 297 | ) 298 | 299 | 300 | @api.resource("/cert/") 301 | class CertificateMain(Resource): 302 | def post(self, certid): 303 | """ 304 | Download the specified certificate. Only the client who requested the 305 | order may access it. 306 | 307 | Args: 308 | certid: 309 | 310 | Returns: 311 | PEM encoded certificate chain. 312 | """ 313 | cert = Certificate.query.filter_by(id=certid).first() 314 | if not cert: 315 | raise ACMEError("Certificate does not exist", 404, "malformed") 316 | 317 | if not cert.order.account.id == g.kid: 318 | raise ACMEError( 319 | "certificate was not generated for this user", 403, "unauthorized" 320 | ) 321 | 322 | cert = cert.serialized 323 | 324 | pem_cert = pkcs7_to_pem_chain(cert) 325 | 326 | return make_response( 327 | pem_cert, 200, {"Content-Type": "application/pem-certificate-chain"} 328 | ) 329 | -------------------------------------------------------------------------------- /serles/wsgi.py: -------------------------------------------------------------------------------- 1 | from serles import create_app 2 | 3 | app = create_app() 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | # read README (actually docs/index.rst): 4 | from os import path 5 | this_directory = path.abspath(path.dirname(__file__)) 6 | with open(path.join(this_directory, 'README.rst'), encoding='utf-8') as f: 7 | long_description = f.read() 8 | 9 | setup( 10 | name="serles-acme", 11 | version="1.0", 12 | packages=find_packages(), 13 | python_requires=">=3.6", 14 | scripts=["bin/serles"], 15 | install_requires=[ 16 | "Flask", 17 | "Flask-RESTful", 18 | "Flask-SQLAlchemy", 19 | "requests", 20 | "jwcrypto", 21 | "cryptography", # Note: if cryptography<3.1, we also need openssl(1) tool 22 | "dnspython", 23 | # for EJBCABackend: 24 | "requests", 25 | "zeep", 26 | # for docs: 27 | "docutils", 28 | # for bin/serles: 29 | "gunicorn", 30 | ], 31 | # metadata to display on PyPI 32 | author="Daten-Verarbeitung-Tirol GmbH", 33 | author_email="project.serles-acme@tirol.gv.at", 34 | description=""" 35 | A tiny ACME (Automatic Certificate Management Environment) Server that 36 | passes actual issuance off to an existing PKI Certificate Authority. 37 | Extensible with plug-ins. Ships with an EJBCA Community backend. 38 | """, 39 | long_description=long_description, 40 | long_description_content_type='text/x-rst', 41 | keywords="pki ejbca acme server certbot", 42 | url="https://github.com/dvtirol/serles-acme", 43 | project_urls={ 44 | "Bug Tracker": "https://github.com/dvtirol/serles-acme/issues", 45 | "Documentation": "https://serles-acme.readthedocs.io/", 46 | "Source Code": "https://github.com/dvtirol/serles-acme", 47 | }, 48 | classifiers=[ 49 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 50 | "Programming Language :: Python :: 3.6", 51 | ], 52 | ) 53 | -------------------------------------------------------------------------------- /tests/Makefile: -------------------------------------------------------------------------------- 1 | coverage: 2 | PYTHONPATH=.:./lib coverage3 run -m pytest tests 3 | coverage3 html --omit=serles/__main__.py serles/*.py 4 | -------------------------------------------------------------------------------- /tests/MockBackend.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | 4 | class Backend: 5 | def __init__(self, config): 6 | pass 7 | 8 | def sign(self, csr, subjectDN, subjectAltNames, email): 9 | assert csr is not None 10 | assert subjectDN is not None 11 | assert type(subjectAltNames) == list 12 | assert email is None or "@" in email 13 | 14 | # returning valid pkcs7 data (but bogus/self-signed cert) 15 | return ( 16 | base64.b64decode( 17 | """ 18 | MIIFQAYJKoZIhvcNAQcCoIIFMTCCBS0CAQExADALBgkqhkiG9w0BBwGgggUTMIIFDzCCAvegAwIB 19 | AgIUceeK5RaBgSsJ+peeahvMhUUV5pMwDQYJKoZIhvcNAQELBQAwFzEVMBMGA1UEAwwMZXhhbXBs 20 | ZS50ZXN0MB4XDTIwMDgxNzEzMTcyNloXDTIxMDgxNzEzMTcyNlowFzEVMBMGA1UEAwwMZXhhbXBs 21 | ZS50ZXN0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAny+AX0yR8Kc7NrEEfYoYKrq/ 22 | ltkSzjrzsIsTYLDpxasE5iCEZejVirqFFMHkeBSfKeoUl6AMXKzpF5fAH7UCxy3qh0Efwa7JhuQz 23 | XFiCMSbqCfLeaSMVCgiB8MY9oWWP7GCJfOEI9vGFBU5jWdO6U/nlw4Y8cxf0OSkINbjzOakD5c19 24 | VIeNp4pTXqzhKH2S4KHDW3Eqc7K4uZmo+a0l/DHH/yQ1TPV5xaWNlC7ozGZ0ykc8nCoxQ8ASgeQ5 25 | u5lgRSIPGthRAXR5NIi/RuWKAwhSWOdSm7DTn5WM9HFqTPg/hlWW1670sTJ2R9sp6hZhljZ58Y0k 26 | jR0+unL4aZga/pSGgMYKoh1DUOTvfDLbtRVknzpMkZttyCKIrzhOOQxqPAjzRN0PItAQaP5cgbp/ 27 | eJTk9s11Nqg7gC8Wufd7saYAof26Cxd+AeTgxiqX4qA7VNd2PZGXuDwhUW60T/mo72C17Cw/6UIy 28 | P7DkwFC/I/6AfnCViGjl7F1hpHkTJyU/OWgGwmRDv7bKxlAKp+UuFD+hd5fT3ko7Rw7om1UH10Wz 29 | VIVR1SD3Ti7iADyjaZGApvK4FZ6GmE/W4DvM34q+a4XdpviLCL/oVRgXBRilxJIS0os05M/Ggh3W 30 | FcEQvwcxEqcmh6rq3S6mjN1Clq6jBNaw1Jhaw9cxj3QZT6n19i0CAwEAAaNTMFEwHQYDVR0OBBYE 31 | FNTwKBK0n+u51/ua7jPyGz9XYnMWMB8GA1UdIwQYMBaAFNTwKBK0n+u51/ua7jPyGz9XYnMWMA8G 32 | A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAIodMgF6EFwHAPJWdS86R3sZ+lD4Z39B 33 | /eWLugvWzAXMJu6Nhuy47cSkNUrA6pxI8VA6qHe9Y2WWtiKomRDXZVX3qVAZv7SLWrKI1w4QVMQN 34 | UYdG1rMmQJUlPyKC8J9f8GZiOCq1JAZ8giF3jhJcpnAe0XocjnV0zxQi1/y3UV8C31v2LbLCuo4S 35 | +R3jtOhR9MfSdszTeKKca+YEFJF88y0CNcRGuN5VEmgGuda93LBBhhuq4HzHrON0UVGSlPSKeQ9+ 36 | MFVobnfwJvubklDmJeXlexbB58DrkMTcZUQ41UfHL9+K0LzwNlQ91rD7bU0dg8Ueee6nBG8+SPZb 37 | hUBibLbORSmQd7SvHz7MNEcAeCIeL7iVBAVeKORJHfu5FTrS5tvB4XKkLerJ+UTjpjzVK3DKAcgR 38 | yl2l2WFFzqDVyTPCuRqgX/iGnVh5ToRrTNl5d0haHRvvWGCQjzVHe9O+Fo/GlSgd5+XGxt6C+Iej 39 | b0T6CvU8uFy4yvNNt5ji1ckUIfOgUyLeDT/POdiw3X5Pbi/aHf5OH5sdZ5sgOkfHe8DiTYHkVnA0 40 | KMCbuiAZBsZfKJKQfF88aCiaTTSu+tqOwqQHC3Gc269MPcafAxZUTjWG9dEmjegqj6mjwYAaeEhd 41 | H8MnxhYRdADuww5g0GZIQOh78dPu7i4gbELgPrMzl3E7oQAxAA==""" 42 | ), 43 | None, 44 | ) 45 | 46 | 47 | class NotBackend: 48 | def __init__(self, config): 49 | pass 50 | -------------------------------------------------------------------------------- /tests/data_config.ini: -------------------------------------------------------------------------------- 1 | [serles] 2 | database = sqlite:///:memory: 3 | backend = tests.MockBackend:Backend 4 | allowedServerIpRanges = ::1/128 5 | excludeServerIpRanges = 127.0.0.2/32 6 | verifyPTR = false 7 | subjectNameTemplate = CN={SAN[0]} 8 | -------------------------------------------------------------------------------- /tests/data_example.test.csr.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htools-org/htools-dane-ca/72d9c396fe48422cfbf9e858a79cdbfaeda92437/tests/data_example.test.csr.bin -------------------------------------------------------------------------------- /tests/data_nocn.csr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htools-org/htools-dane-ca/72d9c396fe48422cfbf9e858a79cdbfaeda92437/tests/data_nocn.csr -------------------------------------------------------------------------------- /tests/data_pemchain.txt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFDzCCAvegAwIBAgIUceeK5RaBgSsJ+peeahvMhUUV5pMwDQYJKoZIhvcNAQEL 3 | BQAwFzEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MB4XDTIwMDgxNzEzMTcyNloXDTIx 4 | MDgxNzEzMTcyNlowFzEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MIICIjANBgkqhkiG 5 | 9w0BAQEFAAOCAg8AMIICCgKCAgEAny+AX0yR8Kc7NrEEfYoYKrq/ltkSzjrzsIsT 6 | YLDpxasE5iCEZejVirqFFMHkeBSfKeoUl6AMXKzpF5fAH7UCxy3qh0Efwa7JhuQz 7 | XFiCMSbqCfLeaSMVCgiB8MY9oWWP7GCJfOEI9vGFBU5jWdO6U/nlw4Y8cxf0OSkI 8 | NbjzOakD5c19VIeNp4pTXqzhKH2S4KHDW3Eqc7K4uZmo+a0l/DHH/yQ1TPV5xaWN 9 | lC7ozGZ0ykc8nCoxQ8ASgeQ5u5lgRSIPGthRAXR5NIi/RuWKAwhSWOdSm7DTn5WM 10 | 9HFqTPg/hlWW1670sTJ2R9sp6hZhljZ58Y0kjR0+unL4aZga/pSGgMYKoh1DUOTv 11 | fDLbtRVknzpMkZttyCKIrzhOOQxqPAjzRN0PItAQaP5cgbp/eJTk9s11Nqg7gC8W 12 | ufd7saYAof26Cxd+AeTgxiqX4qA7VNd2PZGXuDwhUW60T/mo72C17Cw/6UIyP7Dk 13 | wFC/I/6AfnCViGjl7F1hpHkTJyU/OWgGwmRDv7bKxlAKp+UuFD+hd5fT3ko7Rw7o 14 | m1UH10WzVIVR1SD3Ti7iADyjaZGApvK4FZ6GmE/W4DvM34q+a4XdpviLCL/oVRgX 15 | BRilxJIS0os05M/Ggh3WFcEQvwcxEqcmh6rq3S6mjN1Clq6jBNaw1Jhaw9cxj3QZ 16 | T6n19i0CAwEAAaNTMFEwHQYDVR0OBBYEFNTwKBK0n+u51/ua7jPyGz9XYnMWMB8G 17 | A1UdIwQYMBaAFNTwKBK0n+u51/ua7jPyGz9XYnMWMA8GA1UdEwEB/wQFMAMBAf8w 18 | DQYJKoZIhvcNAQELBQADggIBAIodMgF6EFwHAPJWdS86R3sZ+lD4Z39B/eWLugvW 19 | zAXMJu6Nhuy47cSkNUrA6pxI8VA6qHe9Y2WWtiKomRDXZVX3qVAZv7SLWrKI1w4Q 20 | VMQNUYdG1rMmQJUlPyKC8J9f8GZiOCq1JAZ8giF3jhJcpnAe0XocjnV0zxQi1/y3 21 | UV8C31v2LbLCuo4S+R3jtOhR9MfSdszTeKKca+YEFJF88y0CNcRGuN5VEmgGuda9 22 | 3LBBhhuq4HzHrON0UVGSlPSKeQ9+MFVobnfwJvubklDmJeXlexbB58DrkMTcZUQ4 23 | 1UfHL9+K0LzwNlQ91rD7bU0dg8Ueee6nBG8+SPZbhUBibLbORSmQd7SvHz7MNEcA 24 | eCIeL7iVBAVeKORJHfu5FTrS5tvB4XKkLerJ+UTjpjzVK3DKAcgRyl2l2WFFzqDV 25 | yTPCuRqgX/iGnVh5ToRrTNl5d0haHRvvWGCQjzVHe9O+Fo/GlSgd5+XGxt6C+Iej 26 | b0T6CvU8uFy4yvNNt5ji1ckUIfOgUyLeDT/POdiw3X5Pbi/aHf5OH5sdZ5sgOkfH 27 | e8DiTYHkVnA0KMCbuiAZBsZfKJKQfF88aCiaTTSu+tqOwqQHC3Gc269MPcafAxZU 28 | TjWG9dEmjegqj6mjwYAaeEhdH8MnxhYRdADuww5g0GZIQOh78dPu7i4gbELgPrMz 29 | l3E7 30 | -----END CERTIFICATE----- 31 | -------------------------------------------------------------------------------- /tests/data_pkcs7.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htools-org/htools-dane-ca/72d9c396fe48422cfbf9e858a79cdbfaeda92437/tests/data_pkcs7.bin -------------------------------------------------------------------------------- /tests/data_privkey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDKOf+GZ+Tbj1BQ 3 | r2Hy/P4slNul4f6wxA+hSNiq+J7ibaCAGzYOC6pg2gL9TC/MY4svXSng6y7O8cN+ 4 | IosQutwHll2AFEzX60zFOxEaqFHCe9Z/cDLocLNegP+mliNgVbIeG4yByFfLCHzk 5 | ZcPcPlm6E3gYJkDuWCC1tYCUfq3NwDbRAHlVq+KdTImmNL33dmzLWEqavW2JYtz9 6 | 5q+R52KOnfiYclu3IMG2mDgd2d1cOgjOUA+bY44xGsjLLrjn0eXi5zeJHAU4GYvw 7 | SHBGG3V2IBhdDWo4vSPUz8GRNtA+4NiQ7ka9q6A2Pv5QeqUT7VLCbKasP9JcFpCg 8 | JhtJRHr1AgMBAAECggEBAMPWCrg0YJKnZNXeIfLYQaUWuucXxjeUhrZf/0MC5YrN 9 | M0McFurfLS4Bsidt4lQCikVh6S5hUsEhq3m5JgcbjfzjBsUD466ttXjCzLuOCS9Y 10 | Ec7z7rjeezWnouvGNZgTJRy2/Qip5Ss/mwLr8hKVZvsl44Uvdm3adkLB9DTZwKKg 11 | KKKltB4Ubkd0YdaCT+ToH2d7TmZM3oWH2CV53ta4NuilTrDss0hPEw5WjYmsAXwu 12 | /UpxFa9KgY4jgHCvHPBe3ipWplIdzPByNCltgT+vprdWTfYwWkCDF+2E+tfQwVgL 13 | 0ghiK5k+yQLbY4aGUyDrixQRM3FtmqZ2fCFxpBUWYDkCgYEA/dHwR2IBXnz1Ad4G 14 | Bp+36TLdsToybNONkfB+qtfjbEIpzJhJS1o5maebzg84whd0xbmBIdXq+3lypJpw 15 | tE04nn0CftRFfsnnt7665PpXXCX4VOY5vuHFBGEiqqPby6U/ZfxFi/DO10zj3Fy3 16 | Fu9pv7pReXglUW074nMLk+Vq5LsCgYEAy/afnNpGqmHKEqxbk64pQmvhQ6OqfHYT 17 | Y3mQkEGnrn8AsOR74dGJ6LNDIgHP7K3ci+IMGksY4BBu7PgJ9xXNsrh9ybvATbEq 18 | M+v5Q6Mlwpf1VmylBbbeNXkgP7yKtx5RLXFphbpOWvrP4ZdvIdmfVX7rnyr7tdzF 19 | UuUUYBt7/A8CgYEA2A8cVPdFKRR/Tv6a7IqK9/Q3qIRcpiyTBxbMDEvHqMDNaf57 20 | 7au4+mYlh4uHihiDa0hUduPsqfIgt6NLqv9M4gSR2jqBicL+/3dZCk9bi/I6aLKt 21 | lyICHo88/jqLf9eDlyHeZZgvyjAGiu9VXy2fO/izIA443g54+nNp+JRCoL8CgYAR 22 | bNWAEgndfpND+yOrv0JZIVxSagYjsfbAFd+q42EPAFyvwCVxSnSMw9vlaOA5qTTh 23 | YoOEL4xFgEHpztsTF/LF5QsiE/8+4/bmDq/x/ls4f8D/DDMhBNmBGgMOTu5O7LEk 24 | v76zJRKSpUDjVguxiaUaKci5hXORuvXEujt+Znq2MwKBgQDGheDh06O6DXokxSaf 25 | Z7XF3RgmSeJSTfShx+1jezWpzmLXqB3g/MKNTz9ffKiMqQ0s4UGjUf/gfdHUQy8T 26 | JaaGOux0FGLgoWkf/uxTMIuj0I0hXnTD06tZgU3vdR2RXvuvgmrvWBVOz8y1vNEC 27 | 0sABWB+YiSWF2Hy+G5A2aixLzQ== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /tests/good.pemchain: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFDzCCAvegAwIBAgIUceeK5RaBgSsJ+peeahvMhUUV5pMwDQYJKoZIhvcNAQEL 3 | BQAwFzEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MB4XDTIwMDgxNzEzMTcyNloXDTIx 4 | MDgxNzEzMTcyNlowFzEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MIICIjANBgkqhkiG 5 | 9w0BAQEFAAOCAg8AMIICCgKCAgEAny+AX0yR8Kc7NrEEfYoYKrq/ltkSzjrzsIsT 6 | YLDpxasE5iCEZejVirqFFMHkeBSfKeoUl6AMXKzpF5fAH7UCxy3qh0Efwa7JhuQz 7 | XFiCMSbqCfLeaSMVCgiB8MY9oWWP7GCJfOEI9vGFBU5jWdO6U/nlw4Y8cxf0OSkI 8 | NbjzOakD5c19VIeNp4pTXqzhKH2S4KHDW3Eqc7K4uZmo+a0l/DHH/yQ1TPV5xaWN 9 | lC7ozGZ0ykc8nCoxQ8ASgeQ5u5lgRSIPGthRAXR5NIi/RuWKAwhSWOdSm7DTn5WM 10 | 9HFqTPg/hlWW1670sTJ2R9sp6hZhljZ58Y0kjR0+unL4aZga/pSGgMYKoh1DUOTv 11 | fDLbtRVknzpMkZttyCKIrzhOOQxqPAjzRN0PItAQaP5cgbp/eJTk9s11Nqg7gC8W 12 | ufd7saYAof26Cxd+AeTgxiqX4qA7VNd2PZGXuDwhUW60T/mo72C17Cw/6UIyP7Dk 13 | wFC/I/6AfnCViGjl7F1hpHkTJyU/OWgGwmRDv7bKxlAKp+UuFD+hd5fT3ko7Rw7o 14 | m1UH10WzVIVR1SD3Ti7iADyjaZGApvK4FZ6GmE/W4DvM34q+a4XdpviLCL/oVRgX 15 | BRilxJIS0os05M/Ggh3WFcEQvwcxEqcmh6rq3S6mjN1Clq6jBNaw1Jhaw9cxj3QZ 16 | T6n19i0CAwEAAaNTMFEwHQYDVR0OBBYEFNTwKBK0n+u51/ua7jPyGz9XYnMWMB8G 17 | A1UdIwQYMBaAFNTwKBK0n+u51/ua7jPyGz9XYnMWMA8GA1UdEwEB/wQFMAMBAf8w 18 | DQYJKoZIhvcNAQELBQADggIBAIodMgF6EFwHAPJWdS86R3sZ+lD4Z39B/eWLugvW 19 | zAXMJu6Nhuy47cSkNUrA6pxI8VA6qHe9Y2WWtiKomRDXZVX3qVAZv7SLWrKI1w4Q 20 | VMQNUYdG1rMmQJUlPyKC8J9f8GZiOCq1JAZ8giF3jhJcpnAe0XocjnV0zxQi1/y3 21 | UV8C31v2LbLCuo4S+R3jtOhR9MfSdszTeKKca+YEFJF88y0CNcRGuN5VEmgGuda9 22 | 3LBBhhuq4HzHrON0UVGSlPSKeQ9+MFVobnfwJvubklDmJeXlexbB58DrkMTcZUQ4 23 | 1UfHL9+K0LzwNlQ91rD7bU0dg8Ueee6nBG8+SPZbhUBibLbORSmQd7SvHz7MNEcA 24 | eCIeL7iVBAVeKORJHfu5FTrS5tvB4XKkLerJ+UTjpjzVK3DKAcgRyl2l2WFFzqDV 25 | yTPCuRqgX/iGnVh5ToRrTNl5d0haHRvvWGCQjzVHe9O+Fo/GlSgd5+XGxt6C+Iej 26 | b0T6CvU8uFy4yvNNt5ji1ckUIfOgUyLeDT/POdiw3X5Pbi/aHf5OH5sdZ5sgOkfH 27 | e8DiTYHkVnA0KMCbuiAZBsZfKJKQfF88aCiaTTSu+tqOwqQHC3Gc269MPcafAxZU 28 | TjWG9dEmjegqj6mjwYAaeEhdH8MnxhYRdADuww5g0GZIQOh78dPu7i4gbELgPrMz 29 | l3E7 30 | -----END CERTIFICATE----- 31 | -------------------------------------------------------------------------------- /tests/integration.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if ! test $UID -eq 0; then 4 | echo "run test as root" 5 | exit 1 6 | fi 7 | 8 | if ! test -x ./acme_tiny.py; then 9 | echo "acme_tiny.py absent or not executable" 10 | exit 1 11 | fi 12 | 13 | cd $(dirname $(realpath $0)) 14 | 15 | export PYTHONPATH=.:.. 16 | export CONFIG="$(mktemp)" 17 | 18 | logfile="$(mktemp)" 19 | 20 | cat > "$CONFIG" <<'EOF' 21 | [serles] 22 | database = sqlite:///:memory: 23 | backend = tests.MockBackend:Backend 24 | allowedServerIpRanges = 25 | ::1/128 26 | 127.0.0.1/32 27 | excludeServerIpRanges = 127.0.0.2/32 28 | verifyPTR = false 29 | subjectNameTemplate = CN={SAN[0]} 30 | EOF 31 | 32 | python3 -m serles >>$logfile 2>>$logfile & 33 | #python3 -c 'import serles; serles.create_app().run(host="::0", port=8443, ssl_context="adhoc")' >>$logfile 2>>$logfile & 34 | acme=$! 35 | sleep 1 # wait for flask to get ready 36 | 37 | mkdir -p /tmp/well-known/.well-known/acme-challenge 38 | ( cd /tmp/well-known && python3 -m http.server 80 >>$logfile 2>>$logfile; ) & 39 | httpd=$! 40 | 41 | accountkey=/tmp/privkey.pem #misusing privkey for this 42 | csr=/tmp/acmetest.csr 43 | openssl req -newkey rsa:2048 -keyout $accountkey -out $csr -nodes -subj "/CN=example.test" >>$logfile 2>>$logfile 44 | 45 | export http_proxy= 46 | export https_proxy= 47 | python3 ./acme_tiny.py \ 48 | --directory-url https://localhost:8443/directory \ 49 | --acme-dir /tmp/well-known/.well-known/acme-challenge/ \ 50 | --account-key $accountkey \ 51 | --csr $csr 2>>$logfile | 52 | grep -v '^$' | 53 | diff -s - good.pemchain >>$logfile 2>>$logfile 54 | 55 | if test "$?" -eq 0; then 56 | echo "test passed." 57 | else 58 | echo "test failed." 59 | echo 60 | cat $logfile 61 | fi 62 | 63 | kill $httpd $acme 2>/dev/null 64 | 65 | rm -f "$CONFIG" $accountkey $csr 66 | -------------------------------------------------------------------------------- /tests/test_appfactory.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import datetime 3 | import unittest 4 | from unittest.mock import Mock 5 | from unittest.mock import patch 6 | import os 7 | 8 | import serles 9 | 10 | 11 | class AppFactoryTester(unittest.TestCase): 12 | def test_createapp(self): 13 | with tempfile.NamedTemporaryFile() as f: 14 | f.write(b"[serles]\n") 15 | f.write(b"backend=tests.MockBackend\n") 16 | f.write(b"database = sqlite:///:memory:\n") 17 | f.write(b"subjectNameTemplate={SAN[0]}\n") 18 | f.flush() 19 | with patch("os.environ", {"CONFIG": f.name}), unittest.mock.patch.object( 20 | serles, "background_job", lambda n: lambda f: f() 21 | ), unittest.mock.patch.object( 22 | serles, 23 | "Order", 24 | Mock( 25 | query=Mock(filter=lambda q: Mock(all=lambda: [None])), 26 | expires=datetime.datetime.now(datetime.timezone.utc), 27 | ), 28 | ), unittest.mock.patch.object( 29 | serles.db.session, "delete", lambda x: None 30 | ): 31 | serles.create_app() 32 | -------------------------------------------------------------------------------- /tests/test_challengefuns.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ipaddress 3 | import requests 4 | import datetime 5 | import unittest 6 | from unittest.mock import Mock 7 | import mock 8 | import pytest 9 | 10 | import serles.challenge as main 11 | import MockBackend 12 | import dns.resolver 13 | 14 | from cryptography import __version__ as crypto_version 15 | 16 | v = [int(s) if s.isdigit() else -1 for s in crypto_version.split(".")] 17 | has_crypto31 = v[0] >= 3 or (v[0] == 3 and v[1] >= 1) 18 | 19 | 20 | class MockedRequestsSession: 21 | def get(self, *args, **kwargs): 22 | mock_response = Mock() 23 | mock_response.raw.connection.sock.getpeername = lambda: ("", "") 24 | mock_response.text = "token.i9Qes9RMOIbciQjAy6pzYwcZw8IKjKxPP7UZ8fTetps" 25 | return mock_response 26 | 27 | 28 | class MockedRequestsSessionPeerNameFallback: 29 | def get(self, *args, **kwargs): 30 | mock_response = Mock() 31 | mock_response.raw.connection.sock = None 32 | mock_response.text = "token.i9Qes9RMOIbciQjAy6pzYwcZw8IKjKxPP7UZ8fTetps" 33 | return mock_response 34 | 35 | 36 | class MockedRequestsErrorSession: 37 | def get(self, *args, **kwargs): 38 | raise requests.ConnectionError() 39 | 40 | 41 | class MockedRequestsResponseSession: 42 | def get(self, *args, **kwargs): 43 | mock_response = Mock() 44 | mock_response.raw.connection.sock.getpeername = lambda: ("10.0.0.1", "") 45 | mock_response.text = "something wrong" 46 | return mock_response 47 | 48 | 49 | def mockedDNSResolve(qname, rdtype, search=False): 50 | rsp = {"1.0.0.10.in-addr.arpa.": "localhost."}.get(str(qname)) 51 | if not rsp: 52 | raise dns.resolver.NXDOMAIN 53 | return [rsp] 54 | 55 | 56 | # a challenge object, mocked just enought to pass the tests: 57 | mock_authz = Mock(status=main.AuthzStatus.valid) 58 | mock_challenge = Mock() 59 | mock_challenge.authorization.identifier.value = "example.test" 60 | mock_challenge.type = main.ChallengeTypes.http_01 61 | mock_challenge.token = "token" 62 | mock_challenge.authorization.expires = datetime.datetime.now( 63 | datetime.timezone.utc 64 | ) + datetime.timedelta(days=7) 65 | mock_challenge.authorization.order.authorizations = [mock_authz] 66 | mock_challenge.authorization.order.account.jwk = b"""-----BEGIN PUBLIC KEY----- 67 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl939KlZLTx1IXb6oAgNq 68 | Fs/c0t17Gc834+pa/GdVDIkdsbObatNs4l6Qf7lhizANi0JCxID/copS+ZbeTIW5 69 | /xJaWZ+Uw0dHzR4yC+28CaHv2MqHKZgAtFx9wFOavfiP89Sw63HpZxJI+EoAcdP+ 70 | LkroZ2tX47S0hyyzDEO/9h4lIA+LCEfjsNPIkuXCAEYrv+bT+L1ztjIlmAwoR0sP 71 | pCslDx9PB3F55+fBaM6gtxEpJsgG14z7od65EZwDTzoFg4dKURTkTJZ7ZnwMe+zY 72 | nB7cAzUtoA06AJ1DZTP74LcOaMj/rQhs5qLelTb6HwLR3At5ilHkP3K+XddUK/y2 73 | BwIDAQAB 74 | -----END PUBLIC KEY-----""" 75 | 76 | orig_db = main.db 77 | 78 | 79 | class ChallengeFunctionTester(unittest.TestCase): 80 | def setUp(self): 81 | main.backend = MockBackend.Backend([]) 82 | main.config = { 83 | "allowedServerIpRanges": None, 84 | "excludeServerIpRanges": None, 85 | "verifyPTR": False, 86 | "forceTemplateDN": True, 87 | "subjectNameTemplate": "{SAN[0]}", 88 | } 89 | main.db = Mock() # don't commit into the nonexisting database 90 | os.chdir(os.path.dirname(__file__)) 91 | 92 | def tearDown(self): 93 | main.db = orig_db 94 | 95 | def test_verify_challenge_ok(self): 96 | with unittest.mock.patch.object(main, "http_challenge", lambda x: (None, None)): 97 | main.verify_challenge(mock_challenge) 98 | self.assertEqual( 99 | mock_challenge.authorization.order.status, main.OrderStatus.ready 100 | ) 101 | 102 | def test_verify_challenge_err(self): 103 | with unittest.mock.patch.object( 104 | main, "http_challenge", lambda x: ("foo", "bar") 105 | ): 106 | self.assertRaisesRegex( 107 | main.ACMEError, "bar", main.verify_challenge, mock_challenge 108 | ) 109 | 110 | def test_verify_challenge_expired(self): 111 | with unittest.mock.patch.object( 112 | mock_challenge.authorization, 113 | "expires", 114 | datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=7), 115 | ): 116 | self.assertRaisesRegex( 117 | main.ACMEError, 118 | "challenge expired", 119 | main.verify_challenge, 120 | mock_challenge, 121 | ) 122 | 123 | def test_verify_challenge_invalid(self): 124 | with unittest.mock.patch.object( 125 | main.requests, "Session", MockedRequestsSession 126 | ): 127 | old_authz = mock_challenge.authorization.order.authorizations 128 | mock_challenge.authorization.order.authorizations = [ 129 | Mock(status=main.AuthzStatus.invalid) 130 | ] 131 | main.verify_challenge(mock_challenge) 132 | self.assertEqual( 133 | mock_challenge.authorization.order.status, main.OrderStatus.invalid 134 | ) 135 | mock_challenge.authorization.order.authorizations = old_authz 136 | 137 | def test_verify_challenge_unsupported(self): 138 | with unittest.mock.patch.object( 139 | mock_challenge, "type", main.ChallengeTypes.dns_01 140 | ): 141 | self.assertRaisesRegex( 142 | main.ACMEError, 143 | "challenge type not supported", 144 | main.verify_challenge, 145 | mock_challenge, 146 | ) 147 | 148 | def test_http_challenge(self): 149 | with unittest.mock.patch.object( 150 | main.requests, "Session", MockedRequestsSession 151 | ): 152 | result = main.http_challenge(mock_challenge) 153 | self.assertEqual(result, (None, None)) 154 | 155 | def test_http_challenge_connection(self): 156 | with unittest.mock.patch.object( 157 | main.requests, "Session", MockedRequestsErrorSession 158 | ): 159 | result = main.http_challenge(mock_challenge) 160 | self.assertEqual(result[0], "connection") 161 | 162 | def test_http_challenge_response(self): 163 | with unittest.mock.patch.object( 164 | main.requests, "Session", MockedRequestsResponseSession 165 | ): 166 | result = main.http_challenge(mock_challenge) 167 | self.assertEqual(result[0], "incorrectResponse") 168 | 169 | def test_http_challenge_peername1(self): 170 | with unittest.mock.patch.object( 171 | main.requests, "Session", MockedRequestsResponseSession 172 | ), unittest.mock.patch.dict( 173 | main.config, {"allowedServerIpRanges": [ipaddress.ip_network("::1/128")]} 174 | ): 175 | result = main.http_challenge(mock_challenge) 176 | self.assertEqual(result[0], "rejectedIdentifier") 177 | 178 | def test_http_challenge_peername2(self): 179 | with unittest.mock.patch.object( 180 | main.requests, "Session", MockedRequestsResponseSession 181 | ), unittest.mock.patch.dict( 182 | main.config, {"excludeServerIpRanges": [ipaddress.ip_network("10.0.0.0/8")]} 183 | ): 184 | result = main.http_challenge(mock_challenge) 185 | self.assertEqual(result[0], "rejectedIdentifier") 186 | 187 | def test_http_challenge_peername3(self): 188 | with unittest.mock.patch.object( 189 | main.requests, "Session", MockedRequestsSessionPeerNameFallback 190 | ), unittest.mock.patch.object( 191 | main.socket, "fromfd", lambda a, b, c: Mock(getpeername=lambda: ("1::2", 0)) 192 | ), unittest.mock.patch.dict( 193 | main.config, {"allowedServerIpRanges": [ipaddress.ip_network("::1/128")]} 194 | ): 195 | result = main.http_challenge(mock_challenge) 196 | self.assertEqual(result[0], "rejectedIdentifier") 197 | 198 | def test_http_challenge_ptr(self): 199 | with unittest.mock.patch.object( 200 | main.requests, "Session", MockedRequestsResponseSession 201 | ), unittest.mock.patch.dict( 202 | main.config, {"verifyPTR": True} 203 | ), unittest.mock.patch.object( 204 | dns.resolver, "query", mockedDNSResolve 205 | ), unittest.mock.patch.object( 206 | dns.resolver, "resolve", mockedDNSResolve 207 | ): 208 | result = main.http_challenge(mock_challenge) 209 | self.assertEqual(result[0], "rejectedIdentifier") 210 | 211 | def test_pkcs7_to_pem_chain_openssl(self): 212 | der_input = open("data_pkcs7.bin", "rb").read() 213 | pem_output = open("data_pemchain.txt", "r").read() 214 | # force openssl-route: 215 | with mock.patch("cryptography.__version__", "1.0"): 216 | result = main.pkcs7_to_pem_chain(der_input) 217 | self.assertEqual(result, pem_output) 218 | 219 | @pytest.mark.skipif(not has_crypto31, reason="need cryptography >= 3.1") 220 | def test_pkcs7_to_pem_chain_crypto31(self): 221 | der_input = open("data_pkcs7.bin", "rb").read() 222 | pem_output = open("data_pemchain.txt", "r").read() 223 | # force cryptography-route: 224 | with mock.patch("cryptography.__version__", "4.0"): 225 | result = main.pkcs7_to_pem_chain(der_input) 226 | self.assertEqual(result.replace("\n", ""), pem_output.replace("\n", "")) 227 | 228 | def test_check_csr_and_return_cert(self): 229 | csr_input = open("data_example.test.csr.bin", "rb").read() 230 | mock_order = Mock() 231 | mock_order.account.contact = None 232 | example_inval = Mock() 233 | example_inval.value = "example.inval" 234 | example_test = Mock() 235 | example_test.value = "example.test" 236 | 237 | # additional identifiers in CSR: 238 | mock_order.identifiers = [] 239 | self.assertRaisesRegex( 240 | main.ACMEError, 241 | r"set\(\)", 242 | main.check_csr_and_return_cert, 243 | csr_input, 244 | mock_order, 245 | ) 246 | # identifiers missing in CSR: 247 | mock_order.identifiers = [example_inval, example_test] 248 | self.assertRaisesRegex( 249 | main.ACMEError, 250 | "example.inval", 251 | main.check_csr_and_return_cert, 252 | csr_input, 253 | mock_order, 254 | ) 255 | mock_order.identifiers = [example_test] 256 | 257 | result = main.check_csr_and_return_cert(csr_input, mock_order) 258 | good = open("data_pkcs7.bin", "rb").read() 259 | self.assertEqual(result, good) 260 | 261 | with unittest.mock.patch.object( 262 | main.backend, "sign", lambda *x: (None, "error") 263 | ): 264 | self.assertRaisesRegex( 265 | main.ACMEError, 266 | "error", 267 | main.check_csr_and_return_cert, 268 | csr_input, 269 | mock_order, 270 | ) 271 | 272 | def test_check_csr_and_return_cert_nocn(self): 273 | csr_input = open("data_nocn.csr", "rb").read() 274 | mock_order = Mock() 275 | mock_order.account.contact = None 276 | example_inval = Mock() 277 | example_inval.value = "example.inval" 278 | example_test = Mock() 279 | example_test.value = "example.test" 280 | 281 | # additional identifiers in CSR: 282 | mock_order.identifiers = [] 283 | self.assertRaisesRegex( 284 | main.ACMEError, 285 | r"set\(\)", 286 | main.check_csr_and_return_cert, 287 | csr_input, 288 | mock_order, 289 | ) 290 | # identifiers missing in CSR: 291 | mock_order.identifiers = [example_inval, example_test] 292 | self.assertRaisesRegex( 293 | main.ACMEError, 294 | "example.inval", 295 | main.check_csr_and_return_cert, 296 | csr_input, 297 | mock_order, 298 | ) 299 | mock_order.identifiers = [example_test] 300 | 301 | result = main.check_csr_and_return_cert(csr_input, mock_order) 302 | good = open("data_pkcs7.bin", "rb").read() 303 | self.assertEqual(result, good) 304 | 305 | with unittest.mock.patch.object( 306 | main.backend, "sign", lambda *x: (None, "error") 307 | ): 308 | self.assertRaisesRegex( 309 | main.ACMEError, 310 | "error", 311 | main.check_csr_and_return_cert, 312 | csr_input, 313 | mock_order, 314 | ) 315 | -------------------------------------------------------------------------------- /tests/test_configparser.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import unittest 3 | import os, sys 4 | import MockBackend 5 | import serles as main 6 | from serles.configloader import ConfigError 7 | 8 | 9 | class ConfigFunctionTester(unittest.TestCase): 10 | def setUp(self): 11 | os.chdir(os.path.dirname(__file__)) 12 | 13 | def test_configparser_gardenpath(self): 14 | config, backend = main.configloader.load_config_and_backend("data_config.ini") 15 | self.assertIsNotNone(config["allowedServerIpRanges"]) 16 | self.assertIsNotNone(config["excludeServerIpRanges"]) 17 | self.assertEqual(config["subjectNameTemplate"], "CN={SAN[0]}") 18 | self.assertEqual(config["verifyPTR"], False) 19 | # self.assertIsInstance(backend, MockBackend.Backend) #this doesn't work for some reason? 20 | 21 | def test_configparser_noconfig(self): 22 | self.assertRaises( 23 | ConfigError, main.configloader.load_config_and_backend, "/nonexisting-file" 24 | ) 25 | 26 | def test_configparser_configerror(self): 27 | with tempfile.NamedTemporaryFile() as f: 28 | f.write(b"[serles]\n") 29 | f.flush() 30 | self.assertRaisesRegex( 31 | ConfigError, 32 | "define the backend", 33 | main.configloader.load_config_and_backend, 34 | f.name, 35 | ) 36 | f.write(b"backend=tests.MockBackend\n") 37 | f.flush() 38 | self.assertRaisesRegex( 39 | ConfigError, 40 | "no .serles.database= configured", 41 | main.configloader.load_config_and_backend, 42 | f.name, 43 | ) 44 | f.write(b"database=sqlite:///:memory:\n") 45 | f.flush() 46 | self.assertRaisesRegex( 47 | ConfigError, 48 | "subjectNameTemplate", 49 | main.configloader.load_config_and_backend, 50 | f.name, 51 | ) 52 | f.write(b"subjectNameTemplate=x") 53 | f.flush() 54 | 55 | config, backend = main.configloader.load_config_and_backend(f.name) 56 | 57 | self.assertEqual(config["allowedServerIpRanges"], None) 58 | self.assertEqual(config["excludeServerIpRanges"], None) 59 | self.assertEqual(config["verifyPTR"], False) 60 | 61 | def test_configparser_wrongvalue(self): 62 | with tempfile.NamedTemporaryFile() as f: 63 | f.write(b"[serles]\nbackend=nonexisting.module\n") 64 | f.flush() 65 | self.assertRaisesRegex( 66 | ConfigError, 67 | "backend class could not be loaded", 68 | main.configloader.load_config_and_backend, 69 | f.name, 70 | ) 71 | with tempfile.NamedTemporaryFile() as f: 72 | f.write(b"[serles]\nbackend=tests.MockBackend:NotBackend\n") 73 | f.flush() 74 | self.assertRaisesRegex( 75 | ConfigError, 76 | "backend does not define a sign method", 77 | main.configloader.load_config_and_backend, 78 | f.name, 79 | ) 80 | with tempfile.NamedTemporaryFile() as f: 81 | f.write(b"[serles]\n") 82 | f.write(b"database=sqlite:///:memory:\n") 83 | f.write(b"backend=tests.MockBackend\n") 84 | f.write(b"subjectNameTemplate=x\n") 85 | f.write(b"forceTemplateDN=x\n") 86 | f.flush() 87 | self.assertRaisesRegex( 88 | ConfigError, 89 | "forceTemplateDN", 90 | main.configloader.load_config_and_backend, 91 | f.name, 92 | ) 93 | with tempfile.NamedTemporaryFile() as f: 94 | f.write(b"[serles]\n") 95 | f.write(b"database=sqlite:///:memory:\n") 96 | f.write(b"backend=tests.MockBackend\n") 97 | f.write(b"subjectNameTemplate=x\n") 98 | f.write(b"verifyPTR=x\n") 99 | f.flush() 100 | self.assertRaisesRegex( 101 | ConfigError, 102 | "verifyPTR", 103 | main.configloader.load_config_and_backend, 104 | f.name, 105 | ) 106 | -------------------------------------------------------------------------------- /tests/test_ejbcabackend.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import unittest.mock 3 | from unittest.mock import Mock 4 | from serles.backends import ejbca as EJBCABackend 5 | import zeep 6 | 7 | 8 | class MockedClient: 9 | def __init__(self, apiUrl, transport): 10 | pass 11 | 12 | def get_type(self, name): 13 | class zeep_type: 14 | def __init__(self, **kwargs): 15 | pass 16 | 17 | return zeep_type 18 | 19 | class _service: 20 | def certificateRequest(self, userdata, csr, csrtype, none, certtype): 21 | if csr == base64.b64encode(b"fail"): 22 | raise zeep.exceptions.Fault("foo:bar") 23 | return Mock(data=base64.b64encode(b"certificate-data")) 24 | 25 | service = _service() 26 | 27 | 28 | good_config = dict( 29 | backend=dict( 30 | clientCertificate="", 31 | apiUrl="https://example.test:8443/foo?", 32 | caBundle="caBundle", 33 | caName="caName", 34 | endEntityProfileName="endEntityProfileName", 35 | certificateProfileName="certificateProfileName", 36 | entityUsernameScheme="entityUsernameScheme", 37 | entityPasswordScheme="entityPasswordScheme", 38 | ) 39 | ) 40 | 41 | 42 | class HelperFunctionTester(unittest.TestCase): 43 | def test_backend_configerror(self): 44 | config = dict(backend=dict()) 45 | self.assertRaisesRegex( 46 | Exception, "missing config key", EJBCABackend.EjbcaBackend, config 47 | ) 48 | 49 | def test_dnerror(self): 50 | config = dict( 51 | backend=dict( 52 | clientCertificate="", 53 | apiUrl="https://example.test:8443/foo?", 54 | caBundle="caBundle", 55 | caName="caName", 56 | endEntityProfileName="endEntityProfileName", 57 | certificateProfileName="certificateProfileName", 58 | entityUsernameScheme="{fieldmissing}", 59 | entityPasswordScheme="{fieldmissing}", 60 | ) 61 | ) 62 | with unittest.mock.patch.object(EJBCABackend.zeep, "Client", MockedClient): 63 | backend = EJBCABackend.EjbcaBackend(config) 64 | retval = backend.sign(b"csr", "dn", "san", "email") 65 | self.assertEqual(retval, (None, "DN is missing field 'fieldmissing'")) 66 | 67 | def test_sign(self): 68 | with unittest.mock.patch.object(EJBCABackend.zeep, "Client", MockedClient): 69 | backend = EJBCABackend.EjbcaBackend(good_config) 70 | retval = backend.sign(b"csr", "dn", "san", "email") 71 | self.assertEqual(retval, (b"certificate-data", None)) 72 | 73 | def test_failure(self): 74 | with unittest.mock.patch.object(EJBCABackend.zeep, "Client", MockedClient): 75 | backend = EJBCABackend.EjbcaBackend(good_config) 76 | retval = backend.sign(b"fail", "dn", "san", "email") 77 | self.assertEqual(retval, (None, "bar")) 78 | -------------------------------------------------------------------------------- /tests/test_flaskfuns.py: -------------------------------------------------------------------------------- 1 | import json 2 | from werkzeug.exceptions import Forbidden 3 | from werkzeug.datastructures import Headers 4 | import flask 5 | import unittest 6 | from unittest.mock import Mock 7 | import os, sys 8 | import serles as main 9 | import serles.flask_handlers as handlers 10 | import base64 11 | import jwcrypto.jwk, jwcrypto.jws, jwcrypto.common 12 | import datetime 13 | 14 | # from acme_tiny.py (0a9afb2) 15 | # acme_tiny.py is Copyright 2015 Daniel Roesler and licensed under the MIT/X11 license. see https://raw.githubusercontent.com/diafygi/acme-tiny/master/LICENSE 16 | def sign_json(nonce, url, account_key, payload): 17 | import base64, json, subprocess, re, binascii 18 | 19 | _b64 = lambda b: base64.urlsafe_b64encode(b).decode("utf8").replace("=", "") 20 | # parse account key: 21 | proc = subprocess.Popen( 22 | ["openssl", "rsa", "-in", account_key, "-noout", "-text"], 23 | stdin=subprocess.PIPE, 24 | stdout=subprocess.PIPE, 25 | stderr=subprocess.PIPE, 26 | ) 27 | out, err = proc.communicate(None) 28 | pub_pattern = r"modulus:[\s]+?00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)" 29 | pub_hex, pub_exp = re.search( 30 | pub_pattern, out.decode("utf8"), re.MULTILINE | re.DOTALL 31 | ).groups() 32 | pub_exp = "{0:x}".format(int(pub_exp)) 33 | pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp 34 | jwk = { 35 | "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))), 36 | "kty": "RSA", 37 | "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), 38 | } 39 | 40 | # _send_signed_request(): 41 | payload64 = "" if payload is None else _b64(json.dumps(payload).encode("utf8")) 42 | protected = {"url": url, "alg": "RS256"} 43 | if nonce: 44 | protected.update({"nonce": nonce}) 45 | protected.update({"jwk": jwk}) 46 | protected64 = _b64(json.dumps(protected).encode("utf8")) 47 | protected_input = "{0}.{1}".format(protected64, payload64).encode("utf8") 48 | proc = subprocess.Popen( 49 | ["openssl", "dgst", "-sha256", "-sign", account_key], 50 | stdin=subprocess.PIPE, 51 | stdout=subprocess.PIPE, 52 | stderr=subprocess.PIPE, 53 | ) 54 | out, err = proc.communicate(protected_input) 55 | data = json.dumps( 56 | {"protected": protected64, "payload": payload64, "signature": _b64(out)} 57 | ) 58 | return data 59 | 60 | 61 | # endfrom 62 | 63 | 64 | class FlaskFunctionTester(unittest.TestCase): 65 | def test_exception_handler_acmeexception(self): 66 | error = main.ACMEError("foo", 400, "bar") 67 | app = flask.Flask(__name__) 68 | with app.app_context(): 69 | result = main.exception_handler(error) 70 | self.assertIsInstance(result, flask.Response) 71 | self.assertEqual(result.status_code, 400) 72 | result = json.loads(result.data) 73 | self.assertEqual( 74 | result, {"detail": "foo", "type": "urn:ietf:params:acme:error:bar"} 75 | ) 76 | 77 | def test_exception_handler_httperror(self): 78 | error = Forbidden("baz") 79 | app = flask.Flask(__name__) 80 | with app.app_context(): 81 | result = main.exception_handler(error) 82 | self.assertIsInstance(result, flask.Response) 83 | self.assertEqual(result.status_code, 403) 84 | result = json.loads(result.data) 85 | self.assertEqual( 86 | result, {"detail": "baz", "type": "urn:ietf:params:acme:error:malformed"} 87 | ) 88 | 89 | def test_exception_handler_exception(self): 90 | error = Exception("qux") 91 | app = flask.Flask(__name__) 92 | with app.app_context(): 93 | result = main.exception_handler(error) 94 | self.assertIsInstance(result, flask.Response) 95 | self.assertEqual(result.status_code, 500) 96 | result = json.loads(result.data) 97 | self.assertEqual( 98 | result, 99 | {"detail": "qux", "type": "urn:ietf:params:acme:error:serverInternal"}, 100 | ) 101 | 102 | def test_index_header(self): 103 | mock_response = Mock() 104 | mock_response.headers = Headers() 105 | with unittest.mock.patch.object(main.api, "url_for", lambda *x, **y: "/"): 106 | result = main.index_header(mock_response) 107 | self.assertIsNotNone(result.headers.get("Link")) 108 | 109 | def test_inject_nonce(self): 110 | mock_response = Mock() 111 | mock_response.headers = Headers() 112 | with unittest.mock.patch.object(main.Nonces, "new", lambda: "foo"): 113 | result = main.inject_nonce(mock_response) 114 | self.assertIsNotNone(result.headers.get("Replay-Nonce")) 115 | 116 | def test_parse_jws_get(self): 117 | app = flask.Flask(__name__) 118 | with app.test_request_context(method="GET"): 119 | main.parse_jws() # should do nothing 120 | 121 | def test_parse_jws_mimetype(self): 122 | app = flask.Flask(__name__) 123 | with app.test_request_context( 124 | json={}, mimetype="application/json", method="POST" 125 | ): 126 | self.assertRaisesRegex( 127 | main.ACMEError, r"expected application/jose\+json", main.parse_jws 128 | ) 129 | 130 | def test_parse_jws_noprotect(self): 131 | app = flask.Flask(__name__) 132 | key = jwcrypto.jwk.JWK.generate(kty="oct", size=256).export() 133 | with app.test_request_context( 134 | json={}, mimetype="application/jose+json", method="POST" 135 | ): 136 | self.assertRaisesRegex( 137 | main.ACMEError, r"no 'protected' field in request", main.parse_jws 138 | ) 139 | 140 | def test_parse_jws_nokey(self): 141 | app = flask.Flask(__name__) 142 | key = jwcrypto.jwk.JWK.generate(kty="oct", size=256).export() 143 | with app.test_request_context( 144 | json={"protected": "e30="}, mimetype="application/jose+json", method="POST" 145 | ): 146 | self.assertRaisesRegex( 147 | main.ACMEError, r"no public key or key id", main.parse_jws 148 | ) 149 | 150 | def test_parse_jws_kid_nonexisting(self): 151 | app = flask.Flask(__name__) 152 | key = jwcrypto.jwk.JWK.generate(kty="oct", size=256) 153 | with app.test_request_context( 154 | json={ 155 | "protected": base64.urlsafe_b64encode( 156 | json.dumps({"kid": "fakekeyid"}).encode() 157 | ).decode(), 158 | "payload": "Zm9v", # "foo".b64e 159 | "signature": "", 160 | }, 161 | mimetype="application/jose+json", 162 | method="POST", 163 | ): 164 | mockedAccountTbl = Mock() 165 | mockedAccountTbl.query.filter_by = lambda id: Mock(first=lambda: None) 166 | with unittest.mock.patch.object(handlers, "Account", mockedAccountTbl): 167 | self.assertRaisesRegex( 168 | main.ACMEError, r"unknown key id", main.parse_jws 169 | ) 170 | 171 | def test_parse_jws_kid(self): 172 | app = flask.Flask(__name__) 173 | key = jwcrypto.jwk.JWK.generate(kty="oct", size=256) 174 | with app.test_request_context( 175 | json={ 176 | "protected": base64.urlsafe_b64encode( 177 | json.dumps({"kid": "fakekeyid"}).encode() 178 | ).decode(), 179 | "payload": "Zm9v", # "foo".b64e 180 | "signature": "", 181 | }, 182 | mimetype="application/jose+json", 183 | method="POST", 184 | ): 185 | mockedAccountTbl = Mock() 186 | mockedAccountTbl.query.filter_by = lambda id: Mock( 187 | first=lambda: Mock(jwk=open("data_privkey.pem", "rb").read()) 188 | ) 189 | with unittest.mock.patch.object(handlers, "Account", mockedAccountTbl): 190 | self.assertRaisesRegex( 191 | main.ACMEError, r"signed with invalid or wrong key", main.parse_jws 192 | ) 193 | 194 | def test_parse_jws_nosig(self): 195 | app = flask.Flask(__name__) 196 | key = jwcrypto.jwk.JWK.generate(kty="oct", size=256) 197 | with app.test_request_context( 198 | json={ 199 | "protected": base64.urlsafe_b64encode( 200 | json.dumps({"jwk": json.loads(key.export())}).encode() 201 | ).decode(), 202 | "payload": "Zm9v", # "foo".b64e 203 | "signature": "", 204 | }, 205 | mimetype="application/jose+json", 206 | method="POST", 207 | ): 208 | self.assertRaisesRegex( 209 | main.ACMEError, r"signed with invalid or wrong key", main.parse_jws 210 | ) 211 | 212 | def test_parse_jws_nononce(self): 213 | app = flask.Flask(__name__) 214 | key = jwcrypto.jwk.JWK.generate(kty="oct", size=256) 215 | with app.test_request_context( 216 | json=json.loads( 217 | sign_json( 218 | nonce=None, url="", account_key="data_privkey.pem", payload="foo" 219 | ) 220 | ), 221 | mimetype="application/jose+json", 222 | method="POST", 223 | ): 224 | self.assertRaisesRegex(main.ACMEError, r"nonce invalid", main.parse_jws) 225 | 226 | def test_parse_jws_nourl(self): 227 | app = flask.Flask(__name__) 228 | key = jwcrypto.jwk.JWK.generate(kty="oct", size=256) 229 | with app.test_request_context( 230 | json=json.loads( 231 | sign_json( 232 | nonce="x", url="", account_key="data_privkey.pem", payload="foo" 233 | ) 234 | ), 235 | mimetype="application/jose+json", 236 | method="POST", 237 | ), unittest.mock.patch.object(main.Nonces, "check", lambda x: True): 238 | self.assertRaisesRegex(main.ACMEError, r"url doesn't match", main.parse_jws) 239 | 240 | def test_parse_jws_good(self): 241 | app = flask.Flask(__name__) 242 | key = jwcrypto.jwk.JWK.generate(kty="oct", size=256) 243 | with app.test_request_context( 244 | json=json.loads( 245 | sign_json( 246 | nonce="x", 247 | url="http://localhost/", 248 | account_key="data_privkey.pem", 249 | payload="foo", 250 | ) 251 | ), 252 | mimetype="application/jose+json", 253 | method="POST", 254 | ), unittest.mock.patch.object(main.Nonces, "check", lambda x: True): 255 | main.parse_jws() 256 | self.assertEqual(flask.g.payload, "foo") 257 | 258 | def test_nonces(self): 259 | app = flask.Flask(__name__) 260 | app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 261 | app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" 262 | main.db.init_app(app) 263 | main.db.create_all(app=app) 264 | with app.app_context(): 265 | nonce1 = main.Nonces.new() 266 | nonce2 = main.Nonces.new() 267 | self.assertNotEqual(nonce1, nonce2) 268 | 269 | self.assertTrue(main.Nonces.check(nonce1)) 270 | self.assertFalse(main.Nonces.check(nonce1)) # double use forbidden 271 | 272 | # force-expire nonce2: 273 | main.Nonces.query.filter( 274 | main.Nonces.value == nonce2 275 | ).first().expires = datetime.datetime.now(datetime.timezone.utc) 276 | main.db.session.commit() 277 | main.Nonces.purge_expired() 278 | self.assertFalse(main.Nonces.check(nonce2)) 279 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import ipaddress 3 | import unittest 4 | from unittest.mock import Mock 5 | import os, sys 6 | import serles.utils as main 7 | from serles import UTCDateTime 8 | 9 | import dns.resolver 10 | 11 | 12 | def mockedDNSResolve(qname, rdtype, search=False): 13 | rsp = {"localhost.": "127.0.0.1", "1.0.0.127.in-addr.arpa.": "localhost."}.get( 14 | str(qname) 15 | ) 16 | if not rsp: 17 | raise dns.resolver.NXDOMAIN 18 | return [rsp] 19 | 20 | 21 | class MockDNS1x: 22 | query = mockedDNSResolve 23 | 24 | 25 | class HelperFunctionTester(unittest.TestCase): 26 | def test_base64d(self): 27 | result = main.base64d("Pz8_Pw") 28 | self.assertEqual(result, b"????") 29 | 30 | def test_query_and_ptr(self): 31 | with unittest.mock.patch.object( 32 | dns.resolver, "query", mockedDNSResolve 33 | ), unittest.mock.patch.object(dns.resolver, "resolve", mockedDNSResolve): 34 | fqdn = "localhost." 35 | ipaddr = str(main.query(fqdn, "A")[0]) 36 | result = main.get_ptr(ipaddr) 37 | self.assertEqual(result, fqdn) 38 | self.assertEqual(main.query("example.invalid", "A"), []) 39 | 40 | def test_force_dnspython1x(self): 41 | with unittest.mock.patch.object(dns, "resolver", MockDNS1x): 42 | fqdn = "localhost." 43 | main.query(fqdn, "A") 44 | 45 | def test_ip_in_range(self): 46 | ranges = [ipaddress.ip_network("10.1.0.0/24")] 47 | self.assertTrue(main.ip_in_ranges("10.1.0.1", ranges)) 48 | self.assertFalse(main.ip_in_ranges("10.2.0.1", ranges)) 49 | 50 | def test_normalize(self): 51 | result = main.normalize("LOCALHOST.") 52 | self.assertEqual(result, "localhost") 53 | self.assertIsNone(main.normalize(None)) 54 | 55 | def test_utcclass(self): 56 | udt = UTCDateTime() 57 | val = datetime.datetime.now() 58 | result1 = udt.process_bind_param(val, None) 59 | result2 = udt.process_result_value(val, None) 60 | self.assertEqual(result1.tzinfo, datetime.timezone.utc) 61 | self.assertEqual(result2.tzinfo, datetime.timezone.utc) 62 | 63 | def test_backgroundjob(self): 64 | mockTimer = Mock() 65 | mockTimer.setDaemon = lambda x: None 66 | mockTimer.start = lambda: None 67 | with unittest.mock.patch.object(main, "Timer", mockTimer): 68 | 69 | @main.background_job(1) 70 | def _test(): 71 | return 72 | 73 | self.assertTrue(mockTimer.called) 74 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import unittest 3 | from unittest.mock import Mock 4 | import jwcrypto.jwk 5 | import json 6 | import base64 7 | 8 | import serles as main 9 | 10 | 11 | class ViewTester(unittest.TestCase): 12 | def setUp(self): 13 | self.app = flask.Flask(__name__) 14 | self.app.config["PROPAGATE_EXCEPTIONS"] = True 15 | self.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" 16 | self.app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 17 | main.api.init_app(self.app) 18 | main.db.init_app(self.app) 19 | main.db.create_all(app=self.app) 20 | self.app.register_error_handler(Exception, main.exception_handler) 21 | 22 | self.payload = {} 23 | self.kid = "foo" 24 | self.jwk = json.loads(jwcrypto.jwk.JWK.generate(kty="RSA", size=2048).export()) 25 | 26 | @self.app.before_request 27 | def parse_jws(): 28 | from flask import g 29 | 30 | g.kid = self.kid 31 | g.jwk = self.jwk 32 | g.payload = self.payload 33 | 34 | def test_landing(self): 35 | c = self.app.test_client() 36 | r = c.get("/") 37 | self.assertEqual(r.status_code, 200) 38 | self.assertEqual(r.data, b'"Serles ACME Server is running."\n') 39 | 40 | def test_directory(self): 41 | c = self.app.test_client() 42 | r = c.get("/directory") 43 | self.assertEqual(r.status_code, 200) 44 | self.assertEqual(r.json["newNonce"], "http://localhost/newNonce") 45 | self.assertEqual(r.json["newAccount"], "http://localhost/newAccount") 46 | self.assertEqual(r.json["newOrder"], "http://localhost/newOrder") 47 | 48 | def test_nonce(self): 49 | c = self.app.test_client() 50 | r = c.get("/newNonce") 51 | self.assertEqual(r.status_code, 204) 52 | self.assertNotEqual(r.headers.get("Cache-Control"), None) 53 | r = c.head("/newNonce") 54 | self.assertEqual(r.status_code, 200) 55 | self.assertNotEqual(r.headers.get("Cache-Control"), None) 56 | 57 | def test_newAccount_badmail(self): 58 | c = self.app.test_client() 59 | self.payload = {"contact": "nomailto@foo.test"} 60 | r = c.post("/newAccount") 61 | self.assertEqual(r.status_code, 400) 62 | 63 | def test_newAccount(self): 64 | c = self.app.test_client() 65 | # first, new key but onlyReturnExisting: 66 | self.payload = {"onlyReturnExisting": True} 67 | r = c.post("/newAccount") 68 | self.assertEqual(r.status_code, 400) 69 | # then, we run into nonexisting/new account key: 70 | self.payload = {} 71 | r = c.post("/newAccount") 72 | self.assertEqual(r.status_code, 201) 73 | # finally, we run into preexisting key: 74 | r = c.post("/newAccount") 75 | self.assertEqual(r.status_code, 200) 76 | 77 | def test_newOrder(self): 78 | c = self.app.test_client() 79 | # no identifiers: 80 | r = c.post("/newOrder") 81 | self.assertEqual(r.status_code, 400) 82 | # malformed: 83 | self.payload = {"identifiers": [{}]} 84 | r = c.post("/newOrder") 85 | self.assertEqual(r.status_code, 400) 86 | # non-dns identifier: 87 | self.payload = {"identifiers": [{"type": "foo", "value": "bar"}]} 88 | r = c.post("/newOrder") 89 | self.assertEqual(r.status_code, 400) 90 | # correct identifier, but no account: 91 | self.payload = {"identifiers": [{"type": "dns", "value": "example.test"}]} 92 | r = c.post("/newOrder") 93 | self.assertEqual(r.status_code, 400) 94 | # setup: create account 95 | r = c.post("/newAccount") 96 | _, _, account_id = r.headers.get("Location").rpartition("/") 97 | self.assertEqual(r.status_code, 201) 98 | # correct identifier and existing account: 99 | self.kid = account_id 100 | r = c.post("/newOrder") 101 | self.assertEqual(r.status_code, 201) 102 | order_url = r.headers.get("Location") 103 | self.assertEqual(order_url[:32], "http://localhost/order/urn:uuid:") 104 | # test order access 105 | r = c.post(order_url) 106 | self.assertEqual(r.status_code, 200) 107 | # test authz access 108 | r = c.post(r.json["authorizations"][0]) 109 | self.assertEqual(r.status_code, 200) 110 | # test challenge access 111 | with unittest.mock.patch.object( 112 | main.challenge, "http_challenge", lambda x: (None, None) 113 | ): 114 | r = c.post(r.json["challenges"][0]["url"]) 115 | self.assertEqual(r.status_code, 200) 116 | # test finalizing 117 | self.payload = { 118 | "csr": base64.b64encode( 119 | open("data_example.test.csr.bin", "rb").read() 120 | ).decode() 121 | } 122 | r = c.post(order_url + "/finalize") 123 | cert_url = r.json["certificate"] 124 | self.assertEqual(r.status_code, 200) 125 | # test cert dl 126 | r = c.post(cert_url) 127 | self.assertEqual(r.status_code, 200) 128 | # test cert 403 129 | self.kid = "whatever" 130 | r = c.post(cert_url) 131 | self.assertEqual(r.status_code, 403) 132 | 133 | def test_accountMain(self): 134 | c = self.app.test_client() 135 | # setup: create account 136 | self.payload = {"contact": ["mailto:foo@bar.baz"]} 137 | r = c.post("/newAccount") 138 | account_url = r.headers.get("Location") 139 | _, _, account_id = account_url.rpartition("/") 140 | self.assertEqual(r.status_code, 201) 141 | # nonexisting account 142 | self.kid = "foo" 143 | r = c.post("/account/foo") 144 | self.assertEqual(r.status_code, 400) 145 | # existing account, update email broken: 146 | self.kid = account_id 147 | self.payload = {"contact": ["foo@bar.baz"]} 148 | r = c.post("/account/" + account_id) 149 | self.assertEqual(r.status_code, 400) 150 | # existing account, update email ok: 151 | self.kid = account_id 152 | self.payload = {"contact": ["mailto:foo@bar.baz"]} 153 | r = c.post("/account/" + account_id) 154 | print(r.data) 155 | self.assertEqual(r.status_code, 200) 156 | 157 | def test_notfound(self): 158 | c = self.app.test_client() 159 | # nonexisting order 160 | r = c.post("/order/foo") 161 | self.assertEqual(r.status_code, 404) 162 | # nonexisting authz 163 | r = c.post("/authorization/foo") 164 | self.assertEqual(r.status_code, 404) 165 | # nonexisting challenge 166 | r = c.post("/challenge/foo") 167 | self.assertEqual(r.status_code, 404) 168 | # nonexisting order/finalize 169 | self.payload = { 170 | "csr": base64.b64encode( 171 | open("data_example.test.csr.bin", "rb").read() 172 | ).decode() 173 | } 174 | r = c.post("/order/foo/finalize") 175 | self.assertEqual(r.status_code, 404) 176 | # nonexisting cert 177 | r = c.post("/cert/foo") 178 | self.assertEqual(r.status_code, 404) 179 | 180 | def test_ordernotready(self): 181 | c = self.app.test_client() 182 | with self.app.app_context(): 183 | mock_order = Mock() 184 | mock_order.status = "x" 185 | mock_order_q = Mock() 186 | mock_order_q.filter_by = lambda id: Mock(first=lambda: mock_order) 187 | with unittest.mock.patch.object(main.Order, "query", mock_order_q): 188 | # with unittest.mock.patch.object(main.Order.query, 'filter_by', lambda id: Mock(first=lambda: Mock(status=main.OrderStatus.pending))): 189 | self.payload = {"csr": base64.b64encode(b"foo").decode()} 190 | r = c.post("/order/foo/finalize") 191 | self.assertEqual(r.status_code, 403) 192 | --------------------------------------------------------------------------------