├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── acme-v2-fsm.escript ├── acme-v2.escript ├── bin └── eletsencrypt ├── etc └── eletsencrypt.yml ├── examples ├── slave.erl ├── standalone.erl └── webroot.erl ├── rebar.config ├── rebar.lock ├── rebar3 ├── src ├── letsencrypt.app.src ├── letsencrypt.erl ├── letsencrypt_api.erl ├── letsencrypt_elli_handler.erl ├── letsencrypt_jws.erl ├── letsencrypt_ssl.erl └── letsencrypt_utils.erl ├── test ├── letsencrypt_SUITE.erl ├── test_slave_handler.erl └── test_webroot_handler.erl └── tools └── showcert.escript /.gitignore: -------------------------------------------------------------------------------- 1 | .eunit 2 | deps 3 | *.o 4 | *.beam 5 | *.plt 6 | erl_crash.dump 7 | ebin 8 | rel/example_project 9 | .concrete/DEV_MODE 10 | .rebar 11 | _build 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: linux 2 | dist: bionic 3 | 4 | services: 5 | - docker 6 | 7 | addons: 8 | apt: 9 | packages: 10 | - elinks 11 | - rsyslog 12 | 13 | env: 14 | global: 15 | - GOPATH=/tmp/go 16 | - PATH=$GOPATH/bin:$PATH 17 | 18 | cache: 19 | directories: 20 | - $HOME/.kerl 21 | 22 | before_install: 23 | # install erlang/otp 24 | - curl -O https://raw.githubusercontent.com/kerl/kerl/master/kerl 25 | - chmod a+x kerl 26 | - travis_wait 30 ./kerl build 22.2 || true 27 | - ./kerl install 22.2 ~/.kerl/22.2 || true 28 | - . ~/.kerl/22.2/activate 29 | # get pebble docker-compose configuration 30 | - wget https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml 31 | # disable invalid anti-replay nonce random errors 32 | - "sed -ie 's/\\(GODEBUG:.*\\)/\\1\\n PEBBLE_WFE_NONCEREJECT: 0/' docker-compose.yml" 33 | 34 | install: 35 | - ./rebar3 update 36 | - DEBUG=1 ./rebar3 as test compile 37 | 38 | before_script: 39 | - docker-compose -f docker-compose.yml up > /tmp/pebble.log& 40 | - sleep 10 41 | - curl --request POST --data '{"ip":"10.30.50.1"}' http://localhost:8055/set-default-ipv4 42 | 43 | # 44 | script: 45 | - make dialize 46 | - DEBUG=1 ./rebar3 ct > /tmp/ct.log 2>&1 47 | # 48 | after_failure: 49 | - cat /tmp/pebble.log 50 | - cat /tmp/ct.log 51 | - elinks -dump 1 _build/test/logs/ct_run.*/*/run.*/suite.log.html 52 | - elinks -dump 1 _build/test/logs/ct_run.*/*/run.*/letsencrypt_suite.test_* 53 | - elinks -dump 1 _build/test/logs/ct_run.*/*/run.*/unexpected_io.log.html 54 | # 55 | after_success: 56 | - elinks -dump 1 _build/test/logs/ct_run.*/*/run.*/suite.log.html 57 | - elinks -dump 1 _build/test/logs/ct_run.*/*/run.*/letsencrypt_suite.test_* 58 | - elinks -dump 1 _build/test/logs/ct_run.*/*/run.*/unexpected_io.log.html 59 | - cat /tmp/pebble.log 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=bash 2 | 3 | all: 4 | ./rebar3 upgrade 5 | ./rebar3 compile 6 | 7 | dialize: 8 | if [[ "$$TRAVIS_OTP_RELEASE" > "18" ]] ; then\ 9 | ./rebar3 dialyzer;\ 10 | fi 11 | 12 | .PHONY: dialize 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/gbour/letsencrypt-erlang.svg?branch=master)](https://travis-ci.org/gbour/letsencrypt-erlang) 2 | [![Hex.pm](https://img.shields.io/hexpm/v/letsencrypt.svg)](https://hex.pm/packages/letsencrypt) 3 | 4 | # letsencrypt-erlang 5 | Let's Encrypt client library for Erlang 6 | 7 | ## Overview 8 | 9 | Features: 10 | 11 | - [x] ACME v2 12 | - [ ] registering client (with email) 13 | - [x] issuing RSA certificate 14 | - [ ] revoking certificate 15 | - [~] SAN certificate (supplementary domain names) 16 | - [ ] allow EC keys 17 | - [ ] choose RSA key length 18 | - [x] unittests 19 | - [x] hex package 20 | 21 | Modes 22 | - [x] webroot 23 | - [x] slave 24 | - [x] standalone (with http server) 25 | 26 | Validation challenges 27 | - [x] http-01 (http) 28 | - [ ] dns-01 29 | - [ ] proof-of-possession-01 30 | 31 | ## Prerequisites 32 | - openssl >= 1.1.1 (required to generate RSA key and certificate request) 33 | - erlang OTP (tested with 22.2 version, probably works with older versions as well) 34 | 35 | ## Building 36 | 37 | ``` 38 | $> ./rebar3 update 39 | $> ./rebar3 compile 40 | ``` 41 | 42 | ## Quickstart 43 | 44 | You must execute this example on the server targeted by _mydomain.tld_. 45 | Port 80 (http) must be opened and a webserver listening on it (line 1) and serving **/path/to/webroot/** 46 | content. 47 | Both **/path/to/webroot** and **/path/to/certs** MUST be writtable by the erlang process 48 | 49 | ```erlang 50 | 51 | $> $(cd /path/to/webroot && python -m SimpleHTTPServer 80)& 52 | $> ./rebar3 shell 53 | $erl> application:ensure_all_started(letsencrypt). 54 | $erl> letsencrypt:start([{mode,webroot},{webroot_path,"/path/to/webroot"},{cert_path,"/path/to/certs"}]). 55 | $erl> letsencrypt:make_cert(<<"mydomain.tld">>, #{async => false}). 56 | {ok, #{cert => <<"/path/to/certs/mydomain.tld.crt">>, key => <<"/path/to/certs/mydomain.tld.key">>}} 57 | $erl> ^C 58 | 59 | $> ls -1 /path/to/certs 60 | letsencrypt.key 61 | mydomain.tld.crt 62 | mydomain.tld.csr 63 | mydomain.tld.key 64 | ``` 65 | 66 | **Explanations**: 67 | 68 | During the certification process, letsencrypt server returns a challenge and then tries to query the challenge 69 | file from the domain name asked to be certified. 70 | So letsencrypt-erlang is writing challenge file under **/path/to/webroot** directory. 71 | Finally, keys and certificates are written in **/path/to/certs** directory. 72 | 73 | ## Escript 74 | 75 | **bin/eletsencrypt** escript allows certificates management without any lines of Erlang. 76 | Configuration is defined in etc/eletsencrypt.yml 77 | 78 | Options: 79 | * **-h|--help**: show help 80 | * **-l|--list**: list certificates informations 81 | * **-s|--short**: along with *-l*, display informations in short form 82 | * **-r|--renew**: renew expired certificates 83 | * **-f|--force**: along with *-r*, force certificates renewal even if not expired 84 | * **-c|--config CONFIG-FILE**: use *CONFIG-FILE* configuration instead of default one 85 | 86 | Optionally, you can provide the domain you want to apply options as parameter 87 | 88 | 89 | ## API 90 | NOTE: if _optional_ is not written, parameter is required 91 | 92 | * **letsencrypt:start(Params) :: starts letsencrypt client process**: 93 | Params is a list of parameters, choose from the followings: 94 | * **staging** (optional): use staging API (generating fake certificates - default behavior is to use real API) 95 | * **{mode, Mode}**: choose running mode, where **Mode** is one of **webroot**, **slave** or 96 | **standalone** 97 | * **{cert_path, Path}**: pinpoint path to store generated certificates. 98 | Must be writable by erlang process 99 | * **{http_timeout, Timeout}** (integer, optional, default to 30000): http queries timeout 100 | (in milliseconds) 101 | * **{connect_timeout, Timeout}** is **deprecated**, replaced by **http_timeout** 102 | 103 | 104 | Mode-specific parameters: 105 | * _webroot_ mode: 106 | * **{webroot_path, Path}**: pinpoint path to store challenge thumbprints. 107 | Must be writable by erlang process, and available through your webserver as root path 108 | 109 | * _standalone_ mode: 110 | * **{port, Port}** (optional, default to *80*): tcp port to listen for http query for 111 | challenge validation 112 | 113 | returns: 114 | * **{ok, Pid}** with Pid the server process pid 115 | 116 | * **letsencrypt:make_cert(Domain, Opts) :: generate a new certificate for the considered domain name**: 117 | * **Domain**: domain name (string or binary) 118 | * **Opts**: options map 119 | * **async** = true|false (optional, _true_ by default): 120 | * **callback** (optional, used only when _async=true_): function called once certificate has been 121 | generated. 122 | * **san** (list(binary), optional): supplementary domain names added to the certificate. 123 | **san is not available currently, will be reimplemented soon**. 124 | * **challenge** (optional): 'http-01' (default) 125 | 126 | returns: 127 | * in asynchronous mode, function returns **async** 128 | * in synchronous mode, or as asynchronous callback function parameter: 129 | * **{ok, #{cert => <<"/path/to/cert">>, key => <<"/path/to/key">>}}** on success 130 | * **{error, Message}** on error 131 | 132 | examples: 133 | * sync mode (shell is locked several seconds waiting result) 134 | ```erlang 135 | > letsencrypt:make_cert(<<"mydomain.tld">>, #{async => false}). 136 | {ok, #{cert => <<"/path/to/cert">>, key => <<"/path/to/key">>}} 137 | 138 | > % domain tld is incorrect 139 | > letsencrypt:make_cert(<<"invalid.tld">>, #{async => false}). 140 | {error, <<"Error creating new authz :: Name does not end in a public suffix">>} 141 | 142 | > % domain web server does not return challenge file (ie 404 error) 143 | > letsencrypt:make_cert(<<"example.com">>, #{async => false}). 144 | {error, <<"Invalid response from http://example.com/.well-known/acme-challenge/Bt"...>>} 145 | 146 | > % returned challenge is wrong 147 | > letsencrypt:make_cert(<<"example.com">>, #{async => false}). 148 | {error,<<"Error parsing key authorization file: Invalid key authorization: 1 parts">>} 149 | or 150 | {error,<<"Error parsing key authorization file: Invalid key authorization: malformed token">>} 151 | or 152 | {error,<<"The key authorization file from the server did not match this challenge"...>>>} 153 | ``` 154 | * async mode ('async' is written immediately) 155 | ```erlang 156 | > F = fun({Status, Result}) -> io:format("completed: ~p (result= ~p)~n") end. 157 | > letsencrypt:make_cert(<<"example.com">>, #{async => true, callback => F}). 158 | async 159 | > 160 | ... 161 | completed: ok (result= #{cert => <<"/path/to/cert">>, key => <<"/path/to/key">>}) 162 | ``` 163 | 164 | * SAN (**not available currently**) 165 | ```erlang 166 | > letsencrypt:make_cert(<<"example.com">>, #{async => false, san => [<<"www.example.com">>]}). 167 | {ok, #{cert => <<"/path/to/cert">>, key => <<"/path/to/key">>}} 168 | ``` 169 | 170 | * explicit **'http-01'** challenge 171 | ```erlang 172 | > letsencrypt:make_cert(<<"example.com">>, #{async => false, challenge => 'http-01'}). 173 | {ok, #{cert => <<"/path/to/cert">>, key => <<"/path/to/key">>}} 174 | ``` 175 | 176 | 177 | ## Action modes 178 | 179 | ### webroot 180 | 181 | *When you're running a webserver (ie apache or nginx) listening on public http port*. 182 | 183 | ```erlang 184 | on_complete({State, Data}) -> 185 | io:format("letsencrypt certicate issued: ~p (data: ~p)~n", [State, Data]), 186 | case State of 187 | ok -> 188 | io:format("reloading nginx...~n"), 189 | os:cmd("sudo systemctl reload nginx"); 190 | 191 | _ -> pass 192 | end. 193 | 194 | main() -> 195 | letsencrypt:start([{mode,webroot}, staging, {cert_path,"/path/to/certs"}, {webroot_path, "/var/www/html"]), 196 | letsencrypt:make_cert(<<"mydomain.tld">>, #{callback => fun on_complete/1}), 197 | 198 | ok. 199 | ``` 200 | 201 | ### slave 202 | 203 | *When your erlang application is already running an erlang http server, listening on public http port (ie cowboy)*. 204 | 205 | ```erlang 206 | 207 | on_complete({State, Data}) -> 208 | io:format("letsencrypt certificate issued: ~p (data: ~p)~n", [State, Data]). 209 | 210 | main() -> 211 | Dispatch = cowboy_router:compile([ 212 | {'_', [ 213 | {<<"/.well-known/acme-challenge/:token">>, my_letsencrypt_cowboy_handler, []} 214 | ]} 215 | ]), 216 | {ok, _} = cowboy:start_http(my_http_listener, 1, [{port, 80}], 217 | [{env, [{dispatch, Dispatch}]}] 218 | ), 219 | 220 | letsencrypt:start([{mode,slave}, staging, {cert_path,"/path/to/certs"}]), 221 | letsencrypt:make_cert(<<"mydomain.tld">>, #{callback => fun on_complete/1}), 222 | 223 | ok. 224 | ``` 225 | 226 | my_letsencrypt_cowboy_handler.erl contains the code to returns letsencrypt thumbprint matching received token 227 | 228 | ```erlang 229 | -module(my_letsencrypt_cowboy_handler). 230 | 231 | -export([init/3, handle/2, terminate/3]). 232 | 233 | 234 | init(_, Req, []) -> 235 | {Host,_} = cowboy_req:host(Req), 236 | 237 | % NOTES 238 | % - cowboy_req:binding() returns undefined is token not set in URI 239 | % - letsencrypt:get_challenge() returns 'error' if token+thumbprint are not available 240 | % 241 | Thumbprints = letsencrypt:get_challenge(), 242 | {Token,_} = cowboy_req:binding(token, Req), 243 | 244 | {ok, Req2} = case maps:get(Token, Thumprints, undefined) of 245 | Thumbprint -> 246 | cowboy_req:reply(200, [{<<"content-type">>, <<"text/plain">>}], Thumbprint, Req); 247 | 248 | _X -> 249 | cowboy_req:reply(404, Req) 250 | end, 251 | 252 | {ok, Req2, no_state}. 253 | 254 | handle(Req, State) -> 255 | {ok, Req, State}. 256 | 257 | terminate(Reason, Req, State) -> 258 | ok. 259 | ``` 260 | 261 | ### standalone 262 | 263 | *When you have no live http server running on your server*. 264 | 265 | letsencrypt-erlang will start its own webserver just enough time to validate the challenge, then will 266 | stop it immediately after that. 267 | 268 | ```erlang 269 | 270 | on_complete({State, Data}) -> 271 | io:format("letsencrypt certificate issued: ~p (data: ~p)~n", [State, Data]). 272 | 273 | main() -> 274 | letsencrypt:start([{mode,standalone}, staging, {cert_path,"/path/to/certs"}, {port, 80)]), 275 | letsencrypt:make_cert(<<"mydomain.tld">>, #{callback => fun on_complete/1}), 276 | 277 | ok. 278 | ``` 279 | 280 | ## License 281 | 282 | letsencrypt-erlang is distributed under APACHE 2.0 license. 283 | 284 | 285 | -------------------------------------------------------------------------------- /acme-v2-fsm.escript: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env escript 2 | %% -*- erlang -*- 3 | %%! -smp enable -sname factorial -mnesia debug verbose 4 | main([IMode, Domain]) -> 5 | include_libs(), 6 | shotgun:start(), 7 | 8 | Mode = list_to_atom(IMode), 9 | io:format("Mode= ~p, Domain= ~p~n", [Mode, letsencrypt_utils:bin(Domain)]), 10 | %halt(1), 11 | 12 | CertPath = "/tmp/le/certs", 13 | WwwPath = "/tmp/le/webroot", 14 | letsencrypt:start([{mode, Mode}, staging, {cert_path, CertPath}, 15 | {webroot_path, WwwPath}, {port, 5002}]), 16 | 17 | case Mode of 18 | slave -> 19 | io:format("MODE SLAVE~n", []), 20 | os:cmd("erlc -I _build/default/lib/ -o test test/test_slave_handler.erl"), 21 | code:add_pathz(filename:dirname(escript:script_name())++"/test"), 22 | elli:start_link([ 23 | {name , {local, my_test_slave_listener}}, 24 | {callback, test_slave_handler}, 25 | {port , 5002} 26 | ]); 27 | 28 | _ -> ok 29 | end, 30 | 31 | Ret = letsencrypt:make_cert(letsencrypt_utils:bin(Domain), #{async => false}), 32 | 33 | io:format("DONE: ~p~n", [Ret]), 34 | ok. 35 | 36 | include_libs() -> 37 | BaseDir = filename:dirname(escript:script_name()), 38 | io:format("~p~n", [BaseDir]), 39 | [ code:add_pathz(Path) || Path <- filelib:wildcard(BaseDir++"/_build/default/lib/*/ebin") ], 40 | ok. 41 | 42 | -------------------------------------------------------------------------------- /acme-v2.escript: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env escript 2 | %% -*- erlang -*- 3 | %%! -smp enable -sname factorial -mnesia debug verbose 4 | main([Domain]) -> 5 | include_libs(), 6 | shotgun:start(), 7 | 8 | io:format("Domain= ~p~n", [letsencrypt_utils:bin(Domain)]), 9 | %halt(1), 10 | 11 | CertPath = "/tmp/le/certs", 12 | WwwPath = "/tmp/le/webroot", 13 | 14 | Key = letsencrypt_ssl:private_key(undefined, CertPath), 15 | Jws = letsencrypt_jws:init(Key), 16 | 17 | %Uri = "https://acme-v02.api.letsencrypt.org/directory", 18 | %Uri = "https://acme-staging-v02.api.letsencrypt.org/directory", 19 | Opts = #{debug => true, netopts => #{timeout => 30000}}, 20 | 21 | % 1. get directory 22 | {ok, Directory} = letsencrypt_api:directory(staging, Opts), 23 | io:format("directory = ~p~n", [Directory]), 24 | 25 | % 2. get first nonce 26 | {ok, Nonce} = letsencrypt_api:nonce(Directory, Opts), 27 | io:format("~p~n", [Nonce]), 28 | 29 | %TODO: add contact email (optional) => reuse same account 30 | % requires to save key and use this to sign account query with 31 | %TODO: handle account status: "valid", "deactivated", and "revoked" 32 | %TODO: option to check account status only 33 | %TODO: option to list orders 34 | {ok, Account, Location, Nonce2} = letsencrypt_api:account(Directory, Key, Jws#{nonce => Nonce}, Opts), 35 | io:format("~p, ~p, ~p~n", [Location, Account, Nonce2]), 36 | 37 | % build a new Jws with account uri 38 | Jws2 = #{ 39 | alg => maps:get(alg, Jws), 40 | nonce => Nonce2, 41 | kid => Location 42 | }, 43 | {ok, Order, OrderLocation, Nonce3} = letsencrypt_api:order(Directory, 44 | letsencrypt_utils:bin(Domain), Key, Jws2, Opts), 45 | io:format("~p, ~p, ~p~n", [OrderLocation, Order, Nonce3]), 46 | 47 | %TODO: iterate over list 48 | % Order may contains several authorizations urls 49 | AuthzUri = lists:nth(1, maps:get(<<"authorizations">>, Order)), 50 | 51 | 52 | {ok, Authorization, AuthzLocation, Nonce4} = 53 | letsencrypt_api:authorization(AuthzUri, Key, Jws2#{nonce => Nonce3}, Opts), 54 | io:format("~p, ~p, ~p~n", [AuthzLocation, Authorization, Nonce4]), 55 | 56 | % extract http challenge (1st in list) 57 | % TODO: allow choosing challenge to validate 58 | [Challenge] = lists:filter(fun(C) -> 59 | maps:get(<<"type">>, C, error) =:= <<"http-01">> 60 | end, 61 | maps:get(<<"challenges">>, Authorization) 62 | ), 63 | io:format("challenge= ~p~n", [Challenge]), 64 | 65 | % challenges types 66 | % - http-01 67 | % token 68 | % - dsn-01 69 | % - tls-alpn-01 70 | % status: 71 | % - pending 72 | % - processing 73 | % - valid 74 | % - invalid 75 | 76 | % compute thumbprint 77 | AcctKey = maps:get(<<"key">>, Account), 78 | Token = maps:get(<<"token">>, Challenge), 79 | Thumbprint = letsencrypt_jws:keyauth(AcctKey, Token), 80 | io:format("key: ~p, token: ~p, thumb: ~p~n", [AcctKey, Token, Thumbprint]), 81 | 82 | % write thumbprint to file 83 | io:format("writing thumbprint file~n"), 84 | {ok, Fd} = file:open(<<(letsencrypt_utils:bin(WwwPath))/binary, "/.well-known/acme-challenge/", 85 | Token/binary>>, [raw, write, binary]), 86 | file:write(Fd, Thumbprint), 87 | file:close(Fd), 88 | 89 | 90 | % notify server - challenge is ready. 91 | {ok, _, _, Nonce5} = letsencrypt_api:challenge(Challenge, Key, Jws2#{nonce => Nonce4}, Opts), 92 | % wait enough time to let acme server to validate hash file 93 | io:format("wait 20secs~n"), 94 | timer:sleep(20000), 95 | 96 | % checking authorization (is challenge validated ?) 97 | % status should be 'valid' 98 | {ok, _, _, Nonce6} = letsencrypt_api:authorization(AuthzUri, Key, 99 | Jws2#{nonce => Nonce5}, Opts), 100 | 101 | % build & send CSR (with dedicated private key) 102 | Sans = [], 103 | #{file := KeyFile} = letsencrypt_ssl:private_key({new, Domain ++ ".key"}, CertPath), 104 | Csr = letsencrypt_ssl:cert_request(letsencrypt_utils:str(Domain), CertPath, Sans), 105 | io:format("key= ~p, csr= ~p~n", [KeyFile, Csr]), 106 | 107 | % we want 'finalize' value 108 | io:format("finalizing: sending csr~n"), 109 | 110 | % 111 | % fsm 112 | % authorization :: status=valid -> order :: status=ready (loop) until -> 113 | % 114 | % finalize(CSR) :: status=processing -> order (loop until) status=ready -> 115 | % status=ready -> returns order object -> 116 | % 117 | % certificate 118 | 119 | % returns status processing 120 | % status 'ready' 121 | % order MUST be ready before finalizing 122 | {ok, _, _, Nonce42} = letsencrypt_api:order(OrderLocation, Key, Jws2#{nonce => 123 | Nonce6}, Opts), 124 | 125 | % status 'processing' 126 | % finalize may returns either 'processing' or 'ready' 127 | {ok, _, _, Nonce7} = letsencrypt_api:finalize(Order, Csr, Key, 128 | Jws2#{nonce => Nonce42}, Opts), 129 | 130 | % status 'ready' 131 | % order includes 'certificate' link 132 | {ok, FinOrder, _, Nonce8} = letsencrypt_api:order(OrderLocation, Key, Jws2#{nonce => 133 | Nonce7}, Opts), 134 | 135 | %timer:sleep(5000), 136 | %{ok, FinOrder, _, Nonce9} = letsencrypt_api:finalize(Order, Csr, Key, 137 | % Jws2#{nonce => Nonce8}, Opts), 138 | 139 | % download certificate 140 | {ok, Cert} = letsencrypt_api:certificate(FinOrder, Key, Jws2#{nonce => Nonce8}, Opts), 141 | io:format("cert= ~p~n", [Cert]), 142 | {ok, Fd2} = file:open(CertPath++"/"++Domain++".crt", [raw, write, binary]), 143 | file:write(Fd2, Cert), 144 | file:close(Fd2), 145 | 146 | io:format("DONE"), 147 | ok. 148 | 149 | include_libs() -> 150 | BaseDir = filename:dirname(escript:script_name()), 151 | io:format("~p~n", [BaseDir]), 152 | [ code:add_pathz(Path) || Path <- filelib:wildcard(BaseDir++"/_build/default/lib/*/ebin") ], 153 | ok. 154 | 155 | -------------------------------------------------------------------------------- /bin/eletsencrypt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env escript 2 | %% vim: filetype=erlang 3 | 4 | %% Copyright 2015-2016 Guillaume Bour 5 | %% 6 | %% Licensed under the Apache License, Version 2.0 (the "License"); 7 | %% you may not use this file except in compliance with the License. 8 | %% You may obtain a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, software 13 | %% distributed under the License is distributed on an "AS IS" BASIS, 14 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | %% See the License for the specific language governing permissions and 16 | %% limitations under the License. 17 | 18 | -include_lib("public_key/include/public_key.hrl"). 19 | 20 | main([]) -> 21 | include_libs(), 22 | getopt:usage(options_spec_list(), escript:script_name(), "[domain]"); 23 | main(Args) -> 24 | include_libs(), 25 | start([letsencrypt, yamerl]), 26 | 27 | OptsSpecList = options_spec_list(), 28 | {ok, {Opts, Args2}} = getopt:parse(OptsSpecList, Args), 29 | 30 | case parse_opts(Opts, #{action => list, short => false, force => false}) of 31 | help -> main([]); 32 | Ctx -> 33 | #{conf := #{"domains" := Doms}} = Ctx, 34 | 35 | Selection = case Args2 of 36 | [] -> maps:to_list(Doms); 37 | 38 | S2 -> lists:filtermap(fun(D) -> 39 | case maps:get(D, Doms, undefined) of 40 | undefined -> false; 41 | V -> {true, {D, V}} 42 | end end, S2) 43 | end, 44 | 45 | %io:format("~p~n~p~n----~n", [Selection, Ctx]), 46 | do(lists:sort(Selection), Ctx) 47 | end, 48 | 49 | ok. 50 | 51 | start([]) -> 52 | ok; 53 | start([App|T]) -> 54 | application:ensure_all_started(App), 55 | start(T). 56 | 57 | parse_opts([], Ctx) -> 58 | Ctx; 59 | parse_opts([list|T], Ctx) -> 60 | parse_opts(T, Ctx#{action => list}); 61 | parse_opts([renew|T], Ctx) -> 62 | parse_opts(T, Ctx#{action => renew}); 63 | parse_opts([short|T], Ctx) -> 64 | parse_opts(T, Ctx#{short => true}); 65 | parse_opts([force|T], Ctx) -> 66 | parse_opts(T, Ctx#{force => true}); 67 | parse_opts([{config, File}|T], Ctx) -> 68 | [Conf] = yamerl_constr:file(File), 69 | Conf2 = parse_conf(Conf, #{}), 70 | parse_opts(T, Ctx#{conf => Conf2}); 71 | parse_opts([help|_], _) -> 72 | help; 73 | parse_opts([_H|T], Ctx) -> 74 | parse_opts(T, Ctx). 75 | 76 | 77 | parse_conf([], Ctx) -> 78 | Ctx; 79 | parse_conf([{Name, Vals}|T], Ctx) -> 80 | parse_conf(T, Ctx#{Name => maps:from_list(Vals)}); 81 | parse_conf([_|T], Ctx) -> 82 | parse_conf(T, Ctx). 83 | 84 | 85 | do([], _) -> 86 | io:format(" no domain found~n"); 87 | do(Domains, #{action := list, short := Short, conf := #{"general" := General}}) -> 88 | list(Domains, General#{short => Short}); 89 | do(Domains, #{action := renew, force := Force, conf := #{"general" := General}}) -> 90 | renew(Domains, General#{force => Force}). 91 | 92 | list([], _) -> 93 | ok; 94 | list([{Domain, [Opts]}|T], Conf=#{short := false, "renew_threshold" := Threshold}) -> 95 | io:format("~n----~ndomain ~s~n", [color:on_white(color:black(Domain))]), 96 | File = proplists:get_value("path", Opts) ++ "/" ++ Domain ++ ".crt", 97 | 98 | case get_certificate_infos(File) of 99 | {ok, #{subject := Subject, diff := DiffDays, issuer := Issuer, start := Start, 'end' := End, 100 | altnames := AltNames}} -> 101 | 102 | case Subject of 103 | Domain -> pass; 104 | Fail -> io:format(" ~s~n", [ 105 | color:red(io_lib:format("[Certificate subject (~p) do not match file name]", [Fail]))]) 106 | end, 107 | 108 | Msg = if DiffDays < 0 -> 109 | color:red(io_lib:format("[Certificate is expired since ~p days]", [-DiffDays])); 110 | DiffDays < Threshold -> 111 | color:yellow(io_lib:format("[Certificate will expire in ~p days]", [DiffDays])); 112 | true -> 113 | color:green(io_lib:format("[Certificate will expire in ~p days]", [DiffDays])) 114 | end, 115 | io:format(" ~s~n", [Msg]), 116 | 117 | case list_to_binary(Issuer) of 118 | <<"Fake LE", _/binary>> -> 119 | io:format(" ~s~n", [color:rgb([5,3,0], "[Certificate is issued on staging]")]); 120 | <<"Let's Encrypt Authority", _/binary>> -> pass; 121 | _ -> 122 | io:format(" ~s~n", [color:red("[Certificate is not issued by Let's Encrypt authority]")]) 123 | end, 124 | 125 | % general informations 126 | io:format("~n . issuer : ~p~n" ++ 127 | " . created on : ~s~n" ++ 128 | " . expire on : ~s~n", 129 | [Issuer, 130 | date_to_string(Start), 131 | date_to_string(End) 132 | ] 133 | ), 134 | 135 | % alt names 136 | case AltNames of 137 | undefined -> pass; 138 | AltNames -> io:format(" . alt names : ~p~n", [AltNames]) 139 | end, 140 | 141 | ok; 142 | 143 | {error, Err} -> 144 | io:format(" ~s~n", 145 | [color:red(io_lib:format("[Cannot read ~p certificate file (reason=~p)]", [File, Err]))]) 146 | end, 147 | 148 | list(T, Conf); 149 | 150 | list([{Domain, [Opts]}|T], Conf=#{short := true, "renew_threshold" := Threshold}) -> 151 | io:format(" * ~s~s", [color:on_white(color:black(Domain)), [" "||_ <- lists:seq(1,40-string:len(Domain))]]), 152 | File = proplists:get_value("path", Opts) ++ "/" ++ Domain ++ ".crt", 153 | 154 | case get_certificate_infos(File) of 155 | {ok, #{diff := DiffDays, issuer := Issuer}} -> 156 | 157 | Status = if DiffDays < 0 -> color:red("E"); 158 | DiffDays < Threshold -> color:yellow("N"); 159 | true -> color:green("V") 160 | end, 161 | Issued = case list_to_binary(Issuer) of 162 | <<"Fake LE", _/binary>> -> color:rgb([5,3,0], "S"); 163 | <<"Let's Encrypt Authority", _/binary>> -> color:green("P"); 164 | _ -> color:red("X") 165 | end, 166 | 167 | io:format("[~s~s]~n", [Status, Issued]); 168 | 169 | {error, _Err} -> 170 | io:format("[~s_]~n", [color:red("X")]) 171 | end, 172 | 173 | list(T, Conf). 174 | 175 | 176 | renew([], _) -> 177 | ok; 178 | renew([{Domain, [Opts]} | T], Conf=#{force := Force, "renew_threshold" := Threshold}) -> 179 | io:format(" * ~s~s", [color:on_white(color:black(Domain)), [" "||_ <- lists:seq(1,40-string:len(Domain))]]), 180 | 181 | File = proplists:get_value("path", Opts) ++ "/" ++ Domain ++ ".crt", 182 | Resp = case get_certificate_infos(File) of 183 | % certificate file does not exit, we can create it 184 | {error, enoent} -> create_certificate(File, Domain, Opts); 185 | 186 | {ok, #{diff := DiffDays}} -> 187 | if DiffDays >= Threshold andalso Force =/= true -> 188 | io:format("expiration is greater than threshold. Use -f option to force renew~n"), 189 | {error, not_expired}; 190 | true -> 191 | create_certificate(File, Domain, Opts) 192 | end; 193 | 194 | {error, _Err} -> 195 | io:format("[~s_]~n", [_Err]) 196 | end, 197 | 198 | case Resp of 199 | {ok, _Paths} -> 200 | io:format("renewed~n"), 201 | [Action] = proplists:get_value("on_success", Opts, []), 202 | on_success(maps:from_list(Action)); 203 | 204 | {error, not_expired} -> pass; 205 | 206 | {error, Err} -> 207 | io:format("fail to renew (err=~p)~n", [Err]) 208 | end, 209 | renew(T, Conf). 210 | 211 | create_certificate(_File, Domain, Opts) -> 212 | Staging = proplists:get_value("staging", Opts, false), 213 | Path = proplists:get_value("path", Opts), 214 | Mode = list_to_atom(proplists:get_value("mode", Opts)), 215 | Challenge = list_to_atom(proplists:get_value("challenge", Opts, "http-01")), 216 | 217 | LOpts = case Mode of 218 | standalone -> 219 | Port = proplists:get_value("port", Opts), 220 | 221 | [{mode, Mode},{port,Port},{cert_path, Path}]; 222 | 223 | webroot -> 224 | Webroot = proplists:get_value("webroot", Opts), 225 | [{mode, Mode},{cert_path, Path},{webroot_path, Webroot}] 226 | end, 227 | 228 | %io:format("creating ~p (~p,~p,~p)~n", [File, Mode, Path, Port]), 229 | LOpts2 = if Staging -> [staging|LOpts]; true -> LOpts end, 230 | {ok, _Pid} = letsencrypt:start(LOpts2), 231 | Resp = letsencrypt:make_cert(list_to_binary(Domain), #{challenge => Challenge, async => false}), 232 | letsencrypt:stop(), 233 | 234 | Resp. 235 | 236 | 237 | on_success(#{"engine" := "systemd", "unit" := Unit}) -> 238 | Cmd = "systemctl reload '"++Unit++"'", 239 | 240 | Out = os:cmd(Cmd), 241 | io:format("~p~n", [Out]); 242 | on_success(_) -> 243 | % ignored 244 | ok. 245 | 246 | 247 | options_spec_list() -> 248 | [ 249 | {help , $h, "help" , undefined, undefined}, 250 | {list , $l, "list" , undefined, "list certificate(s) w/ informations"}, 251 | {short , $s, "short" , undefined, "short form list"}, 252 | {renew , $r, "renew" , undefined, "create/renew certificate(s)"}, 253 | {force , $f, "force" , undefined, "force renewal event if certificate not expired"}, 254 | {config, $c, "config", {string, "etc/eletsencrypt.yml"}, "eletsencrypt configuration file"} 255 | ]. 256 | 257 | include_libs() -> 258 | BaseDir = filename:dirname(escript:script_name()), 259 | [ code:add_pathz(Path) || Path <- filelib:wildcard(BaseDir++"/../_build/default/lib/*/ebin") ], 260 | 261 | ok. 262 | 263 | 264 | %% 265 | %% CERTIFICATE FUNS 266 | %% 267 | 268 | rdnSeq({rdnSequence, Seq}, Match) -> 269 | rdnSeq(Seq, Match); 270 | rdnSeq([[{'AttributeTypeAndValue', Match, Result}]|_], Match) -> 271 | str(Result); 272 | rdnSeq([_|T], Match) -> 273 | rdnSeq(T, Match); 274 | rdnSeq([], _) -> 275 | undefined. 276 | 277 | exten(asn1_NOVALUE, _) -> 278 | undefined; 279 | exten([], _) -> 280 | undefined; 281 | exten([#'Extension'{extnID = Match, extnValue = Values}|_], Match) -> 282 | [ str(DNS) || DNS <- Values]; 283 | exten([_|T], Match) -> 284 | exten(T, Match). 285 | 286 | str({printableString, Str}) -> 287 | Str; 288 | str({utf8String, Str}) -> 289 | erlang:binary_to_list(Str); 290 | str({dNSName, Str}) -> 291 | Str. 292 | 293 | to_date({utcTime, Date}) -> 294 | case re:run(Date, "(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})Z",[{capture,all_but_first,list}]) of 295 | {match, Matches} -> 296 | [Y,M,D,H,Mm,S] = lists:map(fun(X) -> erlang:list_to_integer(X) end, Matches), 297 | {{2000+Y, M, D}, {H, Mm, S}}; 298 | 299 | _ -> error 300 | end. 301 | 302 | get_certificate_infos(File) -> 303 | case file:read_file(File) of 304 | {error, Err} -> {error, Err}; 305 | {ok, Pem} -> 306 | [{'Certificate',Cert,_}|_] = public_key:pem_decode(Pem), 307 | 308 | #'OTPCertificate'{tbsCertificate = #'OTPTBSCertificate'{ 309 | issuer = Issuer, 310 | validity = #'Validity'{notBefore = Start, notAfter= End}, 311 | subject = _Subject, 312 | extensions = Exts 313 | }} = public_key:pkix_decode_cert(Cert, otp), 314 | 315 | % days before expiration 316 | {DiffDays, {_DiffHours,_,_}} = calendar:seconds_to_daystime( 317 | calendar:datetime_to_gregorian_seconds(to_date(End))- 318 | calendar:datetime_to_gregorian_seconds(calendar:universal_time()) 319 | ), 320 | 321 | 322 | Subject = rdnSeq(_Subject, ?'id-at-commonName'), 323 | AltNames = case exten(Exts, ?'id-ce-subjectAltName') of 324 | undefined -> undefined; 325 | [Subject] -> undefined; 326 | Alts -> lists:delete(Subject, Alts) 327 | end, 328 | 329 | {ok, #{ 330 | subject => Subject, 331 | issuer => rdnSeq(Issuer, ?'id-at-commonName'), 332 | start => to_date(Start), 333 | 'end' => to_date(End), 334 | diff => DiffDays, 335 | altnames => AltNames 336 | }} 337 | end. 338 | 339 | date_to_string({{Year, Month, Day}, {Hour, Min, _}}) -> 340 | io_lib:format("~B-~2..0B-~2..0B ~2..0B:~2..0B GMT", [Year, Month, Day, Hour, Min]); 341 | date_to_string(Date) -> 342 | io_lib:format("invalid date: ~p", [Date]). 343 | -------------------------------------------------------------------------------- /etc/eletsencrypt.yml: -------------------------------------------------------------------------------- 1 | 2 | general: 3 | # in days. Certificates will be renewed when their expiration date will be less than 5 days in the 4 | # future 5 | renew_threshold: 5 6 | 7 | domains: 8 | # list your domains to manage 9 | my.domain.tld: 10 | # path to store private key and certificate file. MUST exist and be writable 11 | - path: /path/to/certificate 12 | # mode to renew certificate 13 | # . standalone : eletsencrypt is starting his own webserver on *port* port 14 | # . webroot : you already have a running webserver 15 | mode: standalone 16 | # either http-01 or tls-sni-01 17 | challenge: http-01 18 | # issue certificate on LE staging platform (default is false) 19 | staging: true 20 | # only with *standalone* mode. Port to listen certificate validation requests 21 | port: 8000 22 | # only with *webroot* mode. Path to store certificate validation file 23 | # webroot: /path/to/webroot 24 | # 25 | # once certificate is renewed, reload web server configuration 26 | # currently only supports *systemd* engine 27 | # on_success: 28 | # - engine: systemd 29 | # unit: nginx 30 | 31 | other.domain: 32 | - path: /path/to/certificate 33 | mode: webroot 34 | webroot: /path/to/webroot 35 | on_success: 36 | - engine: systemd 37 | unit: nginx 38 | 39 | -------------------------------------------------------------------------------- /examples/slave.erl: -------------------------------------------------------------------------------- 1 | %% Copyright 2015-2016 Guillaume Bour 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(slave). 16 | -export([main/1, main/2, on_complete/1]). 17 | 18 | on_complete({State, Data}) -> 19 | io:format("letsencrypt completed: ~p (data: ~p)~n", [State, Data]), 20 | % we can safely stop cowboy and letsencrypt service now 21 | cowboy:stop_listener(my_http_listener), 22 | letsencrypt:stop(). 23 | 24 | main(Domain) -> 25 | main(Domain, 80). 26 | 27 | main(Domain, Port) -> 28 | application:ensure_all_started(letsencrypt), 29 | cowboy:stop_listener(my_http_listener), 30 | 31 | Dispatch = cowboy_router:compile([ 32 | {'_', [ 33 | {<<"/.well-known/acme-challenge/:token">>, letsencrypt_cowboy_handler, []} 34 | ]} 35 | ]), 36 | {ok, _} = cowboy:start_http(my_http_listener, 1, [{port, Port}], 37 | [{env, [{dispatch, Dispatch}]}] 38 | ), 39 | 40 | letsencrypt:start([{mode,slave}, staging, {cert_path, "/tmp"}]), 41 | letsencrypt:make_cert(Domain, #{callback => fun on_complete/1}), 42 | 43 | ok. 44 | -------------------------------------------------------------------------------- /examples/standalone.erl: -------------------------------------------------------------------------------- 1 | %% Copyright 2015-2016 Guillaume Bour 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(standalone). 16 | -export([main/1, main/2, main/3, main/4, on_complete/1]). 17 | 18 | on_complete({State, Data}) -> 19 | io:format("letsencrypt completed: ~p (data: ~p)~n", [State, Data]), 20 | letsencrypt:stop(). 21 | 22 | main(Domain) -> 23 | main(Domain, 80, []). 24 | 25 | main(Domain, Port) -> 26 | main(Domain, Port, []). 27 | 28 | main(Domain, Port, SAN) -> 29 | application:ensure_all_started(letsencrypt), 30 | 31 | letsencrypt:start([{mode,standalone}, staging, {cert_path, "/tmp"}, {port, Port}]), 32 | letsencrypt:make_cert(Domain, #{callback => fun on_complete/1, domains => SAN}), 33 | 34 | ok. 35 | 36 | -spec main(binary(), integer(), list(binary()), 'http-01'|'tls-sni-01') -> ok. 37 | main(Domain, Port, SAN, Challenge) -> 38 | application:ensure_all_started(letsencrypt), 39 | 40 | letsencrypt:start([{mode,standalone}, staging, {cert_path, "/tmp"}, {port, Port}]), 41 | letsencrypt:make_cert(Domain, #{callback => fun on_complete/1, domains => SAN, challenge => Challenge}), 42 | 43 | ok. 44 | -------------------------------------------------------------------------------- /examples/webroot.erl: -------------------------------------------------------------------------------- 1 | %% Copyright 2015-2016 Guillaume Bour 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(webroot). 16 | -export([main/1, on_complete/1]). 17 | 18 | on_complete({State, Data}) -> 19 | io:format("letsencrypt completed: ~p (message: ~p)~n", [State, Data]), 20 | letsencrypt:stop(), 21 | 22 | % we can now reload nginx to use latest certificate 23 | case State of 24 | ok -> 25 | io:format("reloading nginx...~n"), 26 | os:cmd("sudo systemctl reload nginx"); 27 | 28 | _ -> pass 29 | end. 30 | 31 | 32 | main(Domain) -> 33 | application:ensure_all_started(letsencrypt), 34 | 35 | letsencrypt:start([{mode, webroot}, staging, {cert_path, "/etc/letsencrypt/certs"}, {webroot_path, 36 | "/etc/letsencrypt/webroot"}]), 37 | letsencrypt:make_cert(Domain, #{callback => fun on_complete/1}), 38 | 39 | ok. 40 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | {minimum_otp_vsn, "22.2"}. 2 | {erl_opts, [ 3 | debug_info, 4 | {warn_format, 2}, 5 | warn_export_all, 6 | warn_export_vars, 7 | warn_obsolete_guard, 8 | warn_unused_import 9 | % DEBUG mode - use local boulder instance 10 | % ,{d, 'DEBUG'} 11 | % ,{d, 'TEST'} 12 | ]}. 13 | {deps, [ 14 | {shotgun, "0.4.0"}, 15 | {jiffy , "1.0.1"}, 16 | {elli, "3.2.0"}, 17 | 18 | % eletsencrypt escript dependencies 19 | {getopt , "1.0.1"}, 20 | {yamerl , "0.7.0"}, 21 | {erlang_color , "1.0.0"} 22 | ]}. 23 | 24 | {overrides, [ 25 | {override, jiffy, [ 26 | {plugins, [pc]}, 27 | {provider_hooks, [{post, [ 28 | {compile, {pc, compile}}, 29 | {clean, {pc, clean}} 30 | ]}]} 31 | ]} 32 | ]}. 33 | 34 | {dialyzer, [ 35 | % disable *no_match* and *no_unused* temporary 36 | %{warnings, [error_handling, race_conditions]}, 37 | {warnings, [error_handling, race_conditions, no_match, no_unused, no_return]}, 38 | %{get_warnings, true} 39 | {get_warnings, false} 40 | ]}. 41 | 42 | {plugins, [rebar3_auto, rebar3_hex]}. 43 | 44 | {profiles, [ 45 | {test, [ 46 | {erl_opts, [{d, 'TEST'}]} 47 | ]} 48 | ]}. 49 | -------------------------------------------------------------------------------- /rebar.lock: -------------------------------------------------------------------------------- 1 | {"1.1.0", 2 | [{<<"color">>,{pkg,<<"erlang_color">>,<<"1.0.0">>},0}, 3 | {<<"cowlib">>,{pkg,<<"cowlib">>,<<"2.6.0">>},2}, 4 | {<<"elli">>,{pkg,<<"elli">>,<<"3.2.0">>},0}, 5 | {<<"erlang_color">>,{pkg,<<"erlang_color">>,<<"1.0.0">>},0}, 6 | {<<"getopt">>,{pkg,<<"getopt">>,<<"1.0.1">>},0}, 7 | {<<"gun">>,{pkg,<<"gun">>,<<"1.3.1">>},1}, 8 | {<<"jiffy">>,{pkg,<<"jiffy">>,<<"1.0.1">>},0}, 9 | {<<"shotgun">>,{pkg,<<"shotgun">>,<<"0.4.0">>},0}, 10 | {<<"yamerl">>,{pkg,<<"yamerl">>,<<"0.7.0">>},0}]}. 11 | [ 12 | {pkg_hash,[ 13 | {<<"color">>, <<"145FE1D2E65C4516E4F03FEFCA6C2F47EBAD5899D978D70382A5CFE643E4AC9E">>}, 14 | {<<"cowlib">>, <<"8AA629F81A0FC189F261DC98A42243FA842625FEEA3C7EC56C48F4CCDB55490F">>}, 15 | {<<"elli">>, <<"7842861819869EBBFF7230BC77ECF2DF551AE3EAEF5FDE6B01A7561CACCB811E">>}, 16 | {<<"erlang_color">>, <<"145FE1D2E65C4516E4F03FEFCA6C2F47EBAD5899D978D70382A5CFE643E4AC9E">>}, 17 | {<<"getopt">>, <<"C73A9FA687B217F2FF79F68A3B637711BB1936E712B521D8CE466B29CBF7808A">>}, 18 | {<<"gun">>, <<"1489FD96018431B89F401041A9CE0B02B45265247F0FDCF71273BF087C64EA4F">>}, 19 | {<<"jiffy">>, <<"4F25639772CA41202F41BA9C8F6CA0933554283DD4742C90651E03471C55E341">>}, 20 | {<<"shotgun">>, <<"D9931B5F78C79169984C22F573032CCAFE03898C611DF360C0CC9D3756517746">>}, 21 | {<<"yamerl">>, <<"E51DBA652DCE74C20A88294130B48051EBBBB0BE7D76F22DE064F0F3CCF0AAF5">>}]} 22 | ]. 23 | -------------------------------------------------------------------------------- /rebar3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbour/letsencrypt-erlang/566f16d289f8c968e76fafe26ded22b73d38a3cb/rebar3 -------------------------------------------------------------------------------- /src/letsencrypt.app.src: -------------------------------------------------------------------------------- 1 | {application, 'letsencrypt', 2 | [{description, "A letsencrypt.org client library for Erlang"}, 3 | {vsn, "0.9.0"}, 4 | {registered, []}, 5 | {applications, 6 | [kernel, 7 | stdlib, 8 | shotgun, 9 | jiffy, 10 | elli 11 | ]}, 12 | {env,[]}, 13 | {modules, []}, 14 | 15 | {maintainers, ["Guillaume Bour"]}, 16 | {licenses, ["Apache 2.0"]}, 17 | {links, [{"GitHub", "https://github.com/gbour/letsencrypt-erlang"}]}, 18 | {build_tools, ["rebar"]}, 19 | 20 | % include escript & configuration into hex package 21 | {include_files, ["bin/eletsencrypt", "etc/eletsencrypt.yml"]} 22 | ]}. 23 | 24 | -------------------------------------------------------------------------------- /src/letsencrypt.erl: -------------------------------------------------------------------------------- 1 | %% Copyright 2015-2020 Guillaume Bour 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(letsencrypt). 16 | -author("Guillaume Bour "). 17 | -behaviour(gen_fsm). 18 | 19 | -export([make_cert/2, make_cert_bg/2, get_challenge/0]). 20 | -export([start/1, stop/0, init/1, handle_event/3, handle_sync_event/4, handle_info/3, terminate/3, code_change/4]). 21 | -export([idle/3, pending/3, valid/3, finalize/3]). 22 | 23 | -import(letsencrypt_utils, [bin/1, str/1]). 24 | -import(letsencrypt_api, [status/1]). 25 | 26 | % uri format compatible with shotgun library 27 | -type mode() :: 'webroot'|'slave'|'standalone'. 28 | % NOTE: currently only support 'http-01' challenge. 29 | -type challenge_type() :: 'http-01'. 30 | -type nonce() :: binary(). 31 | -type jws() :: #{'alg' => 'RS256', 'jwk' => map(), nonce => undefined|letsencrypt:nonce() }. 32 | -type ssl_privatekey() :: #{'raw' => crypto:rsa_private(), 'b64' => {binary(), binary()}, 'file' => string()}. 33 | 34 | 35 | -define(WEBROOT_CHALLENGE_PATH, <<"/.well-known/acme-challenge">>). 36 | 37 | -record(state, { 38 | % acme environment 39 | env = prod :: staging | prod, 40 | % acme directory (map operation -> uri) 41 | directory = undefined :: undefined | map(), 42 | % acme_srv = ?DEFAULT_API_URL :: uri() | string(), 43 | key_file = undefined :: undefined | string(), 44 | cert_path = "/tmp" :: string(), 45 | 46 | mode = undefined :: undefined | mode(), 47 | % mode = webroot 48 | webroot_path = undefined :: undefined | string(), 49 | % mode = standalone 50 | port = 80 :: integer(), 51 | 52 | intermediate_cert = undefined :: undefined | binary(), 53 | 54 | % state datas 55 | nonce = undefined :: undefined | nonce(), 56 | domain = undefined :: undefined | binary(), 57 | sans = [] :: list(string()), 58 | key = undefined :: undefined | ssl_privatekey(), 59 | jws = undefined :: undefined | jws(), 60 | 61 | account_key = undefined, 62 | order = undefined, 63 | challenges = [] :: map(), 64 | 65 | % certificate/csr key file 66 | cert_key_file = undefined, 67 | 68 | % api options 69 | opts = #{netopts => #{timeout => 30000}} :: map() 70 | 71 | }). 72 | 73 | -type state() :: #state{}. 74 | 75 | % start(Args). 76 | % 77 | % Starts letsencrypt service. 78 | % 79 | % returns: 80 | % {ok, Pid} 81 | % 82 | -spec start(list()) -> {'ok', pid}|{'error', {'already_started',pid()}}. 83 | start(Args) -> 84 | gen_fsm:start_link({global, ?MODULE}, ?MODULE, Args, []). 85 | 86 | % stop(). 87 | % 88 | % Stops letsencrypt service. 89 | % 90 | % returns: 91 | % 'ok' 92 | -spec stop() -> 'ok'. 93 | stop() -> 94 | %NOTE: maintain compatibility with 17.X versions 95 | %gen_fsm:stop({global, ?MODULE}) 96 | gen_fsm:sync_send_all_state_event({global, ?MODULE}, stop). 97 | 98 | %% init(Args). 99 | %% 100 | %% Initialize state machine 101 | %% - init ssl & jws 102 | %% - fetch acme directory 103 | %% - get valid nonce 104 | %% 105 | %% transition: 106 | %% - 'idle' state 107 | -spec init(list( atom() | {atom(),any()} )) -> {ok, idle, state()}. 108 | init(Args) -> 109 | State = setup_mode( 110 | getopts(Args, #state{}) 111 | ), 112 | %{Args2, State} = mode_opts(proplists:get_value(mode, Args), Args), 113 | %State2 = getopts(Args2, State), 114 | %io:format("state= ~p~n", [State2]), 115 | 116 | % initialize key & jws 117 | Key = letsencrypt_ssl:private_key(State#state.key_file, State#state.cert_path), 118 | Jws = letsencrypt_jws:init(Key), 119 | 120 | % request directory 121 | {ok, Directory} = letsencrypt_api:directory(State#state.env, State#state.opts), 122 | 123 | % get first nonce 124 | {ok, Nonce} = letsencrypt_api:nonce(Directory, State#state.opts), 125 | 126 | {ok, idle, State#state{directory=Directory, key=Key, jws=Jws, nonce=Nonce}}. 127 | 128 | 129 | %% 130 | %% PUBLIC funs 131 | %% 132 | 133 | 134 | % make_cert(Domain, Opts). 135 | % 136 | % Generates a new certificate for given Domain 137 | % 138 | % params: 139 | % - Domain: domain name to generate acme certificate for 140 | % - Opts : dictionary of options 141 | % * async (bool): if true, make_cert() blocks until complete and returns 142 | % generated certificate filename 143 | % if false, immediately returns 144 | % * callback: function executed when async = true once domain certificate 145 | % has been successfully generated 146 | % returns: 147 | % - 'async' if async is set (default) 148 | % - {error, Err} if something goes bad 😈 149 | % 150 | -spec make_cert(string()|binary(), map()) -> {'ok', #{cert => binary(), key => binary()}}| 151 | {'error','invalid'}| 152 | async. 153 | make_cert(Domain, Opts=#{async := false}) -> 154 | make_cert_bg(Domain, Opts); 155 | % default to async = true 156 | make_cert(Domain, Opts) -> 157 | _Pid = erlang:spawn(?MODULE, make_cert_bg, [Domain, Opts#{async => true}]), 158 | async. 159 | 160 | -spec make_cert_bg(string()|binary(), map()) -> {'ok', map()}|{'error', 'invalid'}. 161 | make_cert_bg(Domain, Opts=#{async := Async}) -> 162 | Ret = case gen_fsm:sync_send_event({global, ?MODULE}, {create, bin(Domain), Opts}, 15000) of 163 | {error, Err} -> 164 | io:format("error: ~p~n", [Err]), 165 | {error, Err}; 166 | 167 | ok -> 168 | case wait_valid(20) of 169 | ok -> 170 | Status = gen_fsm:sync_send_event({global, ?MODULE}, finalize, 15000), 171 | case wait_finalized(Status, 20) of 172 | {ok, Res} -> {ok, Res}; 173 | Err -> Err 174 | end; 175 | 176 | Error -> 177 | gen_fsm:send_all_state_event({global, ?MODULE}, reset), 178 | Error 179 | end 180 | end, 181 | 182 | case Async of 183 | true -> 184 | Callback = maps:get(callback, Opts, fun(_) -> ok end), 185 | Callback(Ret); 186 | 187 | _ -> 188 | ok 189 | end, 190 | 191 | Ret. 192 | 193 | % get_challenge(). 194 | % 195 | % Returns ongoing challenges with pre-computed thumbprints. 196 | % 197 | % returns: 198 | % #{Challenge => Thumbrint} if ok, 199 | % 'error' if fails 200 | % 201 | -spec get_challenge() -> error|map(). 202 | get_challenge() -> 203 | case catch gen_fsm:sync_send_event({global, ?MODULE}, get_challenge) of 204 | % process not started, wrong state, ... 205 | {'EXIT', _Exc} -> 206 | %io:format("exc: ~p~n", [Exc]), 207 | error; 208 | 209 | % challenge #{token => ..., thumbprint => ...} 210 | C -> C 211 | end. 212 | 213 | 214 | %% 215 | %% gen_server API 216 | %% 217 | 218 | 219 | % state 'idle' 220 | % 221 | % When awaiting for certificate request. 222 | % 223 | % idle(get_challenge) :: nothing done 224 | % 225 | idle(get_challenge, _, State) -> 226 | {reply, no_challenge, idle, State}; 227 | 228 | % idle({create, Domain, Opts}). 229 | % 230 | % Starts a new certificate delivery process. 231 | % - create new account 232 | % - create new order (incl 233 | % - requires authorization (returns challenges list) 234 | % - initiate choosen challenge 235 | % 236 | % transition: 237 | % - 'idle' if process failed 238 | % - 'pending' waiting for challenges to be completes 239 | % 240 | idle({create, Domain, _Opts}, _, State=#state{directory=Dir, key=Key, jws=Jws, 241 | nonce=Nonce, opts=Opts}) -> 242 | % 'http-01' or 'tls-sni-01' 243 | % TODO: validate type 244 | ChallengeType = maps:get(challenge, Opts, 'http-01'), 245 | 246 | %Conn = get_conn(State), 247 | %Nonce = get_nonce(Conn, State), 248 | %TODO: SANs 249 | %SANs = maps:get(san, Opts, []), 250 | 251 | {ok, Accnt, Location, Nonce2} = letsencrypt_api:account(Dir, Key, Jws#{nonce => Nonce}, Opts), 252 | AccntKey = maps:get(<<"key">>, Accnt), 253 | 254 | Jws2 = #{ 255 | alg => maps:get(alg, Jws), 256 | nonce => Nonce2, 257 | kid => Location 258 | }, 259 | %TODO: checks order is ok 260 | {ok, Order, OrderLocation, Nonce3} = letsencrypt_api:order(Dir, bin(Domain), Key, Jws2, Opts), 261 | % we need to keep trace of order location 262 | Order2 = Order#{<<"location">> => OrderLocation}, 263 | 264 | %Nonce2 = letsencrypt_api:new_reg(Conn, BasePath, Key, JWS#{nonce => Nonce}), 265 | %AuthzResp = authz([Domain|SANs], ChallengeType, State#state{conn=Conn, nonce=Nonce2}), 266 | AuthUris = maps:get(<<"authorizations">>, Order), 267 | AuthzResp = authz(ChallengeType, AuthUris, State#state{domain=Domain, jws=Jws2, account_key=AccntKey, nonce=Nonce3}), 268 | {StateName, Reply, Challenges, Nonce5} = case AuthzResp of 269 | {error, Err, Nonce3} -> 270 | {idle, {error, Err}, nil, Nonce3}; 271 | 272 | {ok, Xchallenges, Nonce4} -> 273 | {pending, ok, Xchallenges, Nonce4} 274 | end, 275 | 276 | {reply, Reply, StateName, 277 | State#state{domain=Domain, jws=Jws2, nonce=Nonce5, order=Order2, challenges=Challenges, sans=[], account_key=AccntKey}}. 278 | 279 | 280 | % state 'pending' 281 | % 282 | % When challenges are on-the-go. 283 | % 284 | % pending(get_challenge). 285 | % 286 | % Returns list of challenges currently on-the-go with pre-computed thumbprints. 287 | % 288 | pending(get_challenge, _, State=#state{account_key=AccntKey, challenges=Challenges}) -> 289 | % #{Domain => #{ 290 | % Token => Thumbprint, 291 | % ... 292 | % }} 293 | % 294 | Thumbprints = maps:from_list(lists:map( 295 | fun(#{<<"token">> := Token}) -> 296 | {Token, letsencrypt_jws:keyauth(AccntKey, Token)} 297 | end, maps:values(Challenges) 298 | )), 299 | {reply, Thumbprints, pending, State}; 300 | 301 | % pending(check). 302 | % 303 | % Checks if all challenges are completed. 304 | % Switch to 'valid' state iff all challenges are validated only 305 | % 306 | % transition: 307 | % - 'pending' if at least one challenge is not complete yet 308 | % - 'valid' if all challenges are complete 309 | % 310 | %TODO: handle other states explicitely (allowed values are 'invalid', 'deactivated', 311 | % 'expired' and 'revoked' 312 | % 313 | pending(_Action, _, State=#state{order=#{<<"authorizations">> := Authzs}, nonce=Nonce, key=Key, jws=Jws, opts=Opts}) -> 314 | % checking status for each authorization 315 | {StateName, Nonce2} = lists:foldl(fun(AuthzUri, {Status, InNonce}) -> 316 | {ok, Authz, _, OutNonce} = letsencrypt_api:authorization(AuthzUri, Key, Jws#{nonce => InNonce}, Opts), 317 | 318 | Status2 = maps:get(<<"status">>, Authz), 319 | %{Status2, Msg2} = letsencrypt_api:challenge(Challengestatus, Conn, UriPath), 320 | %io:format("~p: ~p (~p)~n", [_K, Status2, Msg2]), 321 | 322 | Ret = case {Status, Status2} of 323 | {valid , <<"valid">>} -> valid; 324 | {pending, _} -> pending; 325 | {_ , <<"pending">>} -> pending; 326 | %TODO: we must not let that openbar :) 327 | {valid , Status2} -> Status2; 328 | {Status , _} -> Status 329 | end, 330 | {Ret, OutNonce} 331 | end, {valid, Nonce}, Authzs), 332 | 333 | %io:format(":: challenge state -> ~p~n", [Reply]), 334 | % reply w/ StateName 335 | {reply, StateName, StateName, State#state{nonce=Nonce2}}. 336 | 337 | % state 'valid' 338 | % 339 | % When challenges has been successfully completed. 340 | % Finalize acme order and generate ssl certificate. 341 | % 342 | % returns: 343 | % Status: order status 344 | % 345 | % transition: 346 | % state 'finalize' 347 | valid(_, _, State=#state{mode=Mode, domain=Domain, sans=SANs, cert_path=CertPath, 348 | order=Order, key=Key, jws=Jws, nonce=Nonce, opts=Opts}) -> 349 | 350 | challenge_destroy(Mode, State), 351 | 352 | %NOTE: keyfile is required for csr generation 353 | #{file := KeyFile} = letsencrypt_ssl:private_key({new, str(Domain) ++ ".key"}, CertPath), 354 | Csr = letsencrypt_ssl:cert_request(str(Domain), CertPath, SANs), 355 | {ok, FinOrder, _, Nonce2} = letsencrypt_api:finalize(Order, Csr, Key, 356 | Jws#{nonce => Nonce}, Opts), 357 | 358 | {reply, status(maps:get(<<"status">>, FinOrder, nil)), finalize, 359 | State#state{order=FinOrder#{<<"location">> => maps:get(<<"location">>, Order)}, 360 | cert_key_file=KeyFile, nonce=Nonce2}}. 361 | 362 | % state 'finalize' 363 | % 364 | % When order is being finalized, and certificate generation is ongoing. 365 | % 366 | % finalize(processing) 367 | % 368 | % Wait for certificate generation being complete (order status == 'valid'). 369 | % 370 | % returns: 371 | % Status : order status 372 | % 373 | % transition: 374 | % state 'processing' : still ongoing 375 | % state 'valid' : certificate is ready 376 | finalize(processing, _, State=#state{order=Order, key=Key, jws=Jws, nonce=Nonce, opts=Opts}) -> 377 | {ok, Order2, _, Nonce2} = letsencrypt_api:order( 378 | maps:get(<<"location">>, Order, nil), 379 | Key, Jws#{nonce => Nonce}, Opts), 380 | {reply, status(maps:get(<<"status">>, Order2, nil)), finalize, 381 | State#state{order=Order2, nonce=Nonce2} 382 | }; 383 | 384 | % finalize(valid) 385 | % 386 | % Download certificate & save into file. 387 | % 388 | % returns; 389 | % #{key, cert} 390 | % - Key is certificate private key filename 391 | % - Cert is certificate PEM filename 392 | % 393 | % transition: 394 | % state 'idle' : fsm complete, going back to initial state 395 | finalize(valid, _, State=#state{order=Order, domain=Domain, cert_key_file=KeyFile, 396 | cert_path=CertPath, key=Key, jws=Jws, nonce=Nonce, 397 | opts=Opts}) -> 398 | % download certificate 399 | {ok, Cert} = letsencrypt_api:certificate(Order, Key, Jws#{nonce => Nonce}, Opts), 400 | CertFile = letsencrypt_ssl:certificate(str(Domain), Cert, CertPath), 401 | 402 | {reply, {ok, #{key => bin(KeyFile), cert => bin(CertFile)}}, idle, 403 | State#state{nonce=undefined}}; 404 | 405 | % finalize(Status) 406 | % 407 | % Any other order status leads to exception. 408 | % 409 | finalize(Status, _, State) -> 410 | io:format("unknown finalize status ~p~n", [Status]), 411 | {reply, {error, Status}, finalize, State}. 412 | 413 | 414 | %%% 415 | %%% 416 | %%% 417 | 418 | 419 | handle_event(reset, _StateName, State=#state{mode=Mode}) -> 420 | %io:format("reset from ~p state~n", [StateName]), 421 | challenge_destroy(Mode, State), 422 | {next_state, idle, State}; 423 | 424 | handle_event(_, StateName, State) -> 425 | io:format("async evt: ~p~n", [StateName]), 426 | {next_state, StateName, State}. 427 | 428 | handle_sync_event(stop,_,_,_) -> 429 | {stop, normal, ok, #state{}}; 430 | handle_sync_event(_,_, StateName, State) -> 431 | io:format("sync evt: ~p~n", [StateName]), 432 | {reply, ok, StateName, State}. 433 | 434 | handle_info(_, StateName, State) -> 435 | {next_state, StateName, State}. 436 | 437 | terminate(_,_,_) -> 438 | ok. 439 | 440 | code_change(_, StateName, State, _) -> 441 | {ok, StateName, State}. 442 | 443 | 444 | %% 445 | %% PRIVATE funs 446 | %% 447 | 448 | 449 | % getopts(Args) 450 | % 451 | % Parse letsencrypt:start() options. 452 | % 453 | % Available options are: 454 | % - staging : runs in staging environment (running on production either) 455 | % - key_file : reuse an existing ssl key 456 | % - cert_path : path to read/save ssl certificate, key and csr request 457 | % - connect_timeout: timeout for acme api requests (seconds) 458 | % THIS OPTION IS DEPRECATED, REPLACED BY http_timeout 459 | % - http_timeout : timeout for acme api requests (seconds) 460 | % 461 | % returns: 462 | % - State (type record 'state') filled with options values 463 | % 464 | % exception: 465 | % - 'badarg' if unrecognized option 466 | % 467 | -spec getopts(list(atom()|{atom(),any()}), state()) -> state(). 468 | getopts([], State) -> 469 | State; 470 | getopts([staging|Args], State) -> 471 | getopts( 472 | Args, 473 | State#state{env = staging} 474 | ); 475 | getopts([{mode, Mode}|Args], State) -> 476 | getopts( 477 | Args, 478 | State#state{mode=Mode} 479 | ); 480 | getopts([{key_file, KeyFile}|Args], State) -> 481 | getopts( 482 | Args, 483 | State#state{key_file = KeyFile} 484 | ); 485 | getopts([{cert_path, Path}|Args], State) -> 486 | getopts( 487 | Args, 488 | State#state{cert_path = Path} 489 | ); 490 | getopts([{webroot_path, Path}|Args], State) -> 491 | getopts( 492 | Args, 493 | State#state{webroot_path = Path} 494 | ); 495 | getopts([{port, Port}|Args], State) -> 496 | getopts( 497 | Args, 498 | State#state{port = Port} 499 | ); 500 | % for compatibility. Will be removed in future release 501 | getopts([{connect_timeout, Timeout}|Args], State) -> 502 | io:format("'connect_timeout' option is deprecated. Please use 'http_timeout' instead~n", []), 503 | getopts( 504 | Args, 505 | State#state{opts = #{netopts => #{timeout => Timeout}}} 506 | ); 507 | getopts([{http_timeout, Timeout}|Args], State) -> 508 | getopts( 509 | Args, 510 | State#state{opts = #{netopts => #{timeout => Timeout}}} 511 | ); 512 | getopts([Unk|_], _) -> 513 | io:format("unknow parameter: ~p~n", [Unk]), 514 | %throw({badarg, io_lib:format("unknown ~p parameter", [Unk])}). 515 | throw(badarg). 516 | 517 | % setup_mode(State). 518 | % 519 | % Setup context of choosen mode. 520 | % 521 | % returns: 522 | % - State, as received 523 | % 524 | % exception: 525 | % - 'misarg' if a parameter is missing for choosen mode, 526 | % - 'invalid_mode' if mode is not valid 527 | % 528 | -spec setup_mode(state()) -> state(). 529 | setup_mode(#state{mode=webroot, webroot_path=undefined}) -> 530 | io:format("missing 'webroot_path' parameter", []), 531 | throw(misarg); 532 | setup_mode(State=#state{mode=webroot, webroot_path=Path}) -> 533 | %TODO: check directory is writeable 534 | %TODO: handle errors 535 | %TODO: protect against injections ? 536 | os:cmd(string:join(["mkdir -p '", Path, str(?WEBROOT_CHALLENGE_PATH), "'"], "")), 537 | State; 538 | setup_mode(State=#state{mode=standalone, port=_Port}) -> 539 | %TODO: checking port is unused ? 540 | State; 541 | setup_mode(State=#state{mode=slave}) -> 542 | State; 543 | % every other mode value is invalid 544 | setup_mode(#state{mode=Mode}) -> 545 | io:format("invalid '~p' mode", [Mode]), 546 | throw(invalid_mode). 547 | 548 | % wait_valid(X). 549 | % 550 | % Loops X time on authorization check until challenges are all validated 551 | % (waits incrementing time between each trial). 552 | % 553 | % returns: 554 | % - {error, timeout} if failed after X loops 555 | % - {error, Err} if another error 556 | % - 'ok' if succeed 557 | % 558 | -spec wait_valid(0..10) -> ok|{error, any()}. 559 | wait_valid(X) -> 560 | wait_valid(X,X). 561 | 562 | -spec wait_valid(0..10, 0..10) -> ok|{error, any()}. 563 | wait_valid(0,_) -> 564 | {error, timeout}; 565 | wait_valid(Cnt,Max) -> 566 | case gen_fsm:sync_send_event({global, ?MODULE}, check, 15000) of 567 | valid -> ok; 568 | pending -> 569 | timer:sleep(500*(Max-Cnt+1)), 570 | wait_valid(Cnt-1,Max); 571 | {_ , Err} -> {error, Err} 572 | end. 573 | 574 | % wait_finalized(X). 575 | % 576 | % Loops X time on order being finalized 577 | % (waits incrementing time between each trial). 578 | % 579 | % returns: 580 | % - {error, timeout} if failed after X loops 581 | % - {error, Err} if another error 582 | % - {'ok', Response} if succeed 583 | % 584 | -spec wait_finalized(atom(), 0..10) -> {ok, map()}|{error, timeout|any()}. 585 | wait_finalized(Status, X) -> 586 | wait_finalized(Status,X,X). 587 | 588 | -spec wait_finalized(atom(), 0..10, 0..10) -> {ok, map()}|{error, timeout|any()}. 589 | wait_finalized(_, 0,_) -> 590 | {error, timeout}; 591 | wait_finalized(Status, Cnt,Max) -> 592 | case gen_fsm:sync_send_event({global, ?MODULE}, Status, 15000) of 593 | {ok, Res} -> {ok, Res}; 594 | valid -> 595 | timer:sleep(500*(Max-Cnt+1)), 596 | wait_finalized(valid, Cnt-1,Max); 597 | processing -> 598 | timer:sleep(500*(Max-Cnt+1)), 599 | wait_finalized(processing, Cnt-1,Max); 600 | {_ , Err} -> {error, Err}; 601 | Any -> Any 602 | end. 603 | 604 | % authz(ChallenteType, AuthzUris, State). 605 | % 606 | % Perform acme authorization and selected challenge initialization. 607 | % 608 | % returns: 609 | % {ok, Challenges, Nonce} 610 | % {error, Error, Nonce} 611 | % 612 | -spec authz(challenge_type(), list(binary()), state()) -> {error, uncatched|binary(), nonce()}| 613 | {ok, map(), nonce()}. 614 | authz(ChallengeType, AuthzUris, State=#state{mode=Mode}) -> 615 | case authz_step1(AuthzUris, ChallengeType, State, #{}) of 616 | {error, Err, Nonce} -> 617 | {error, Err, Nonce}; 618 | 619 | {ok, Challenges, Nonce2} -> 620 | %io:format("challenges: ~p ~p~n", [Challenges, Domain]), 621 | challenge_init(Mode, State, ChallengeType, Challenges), 622 | 623 | case authz_step2(maps:to_list(Challenges), State#state{nonce=Nonce2}) of 624 | {ok, Nonce3} -> 625 | {ok, Challenges, Nonce3}; 626 | Err -> 627 | Err 628 | end 629 | end. 630 | 631 | % authz_step1(AuthzUris, ChallengeType, State). 632 | % 633 | % Request authorizations. 634 | % 635 | % returns: 636 | % {ok, Challenges, Nonce} 637 | % - Challenges is map of Uri -> Challenge, where Challenge is of ChallengeType type 638 | % - Nonce is a new valid replay-nonce 639 | % 640 | -spec authz_step1(list(binary()), challenge_type(), state(), map()) -> {ok, map(), nonce()} | 641 | {error, uncatched|binary(), nonce()}. 642 | authz_step1([], _, #state{nonce=Nonce}, Challenges) -> 643 | {ok, Challenges, Nonce}; 644 | authz_step1([Uri|T], ChallengeType, 645 | State=#state{nonce=Nonce, key=Key, jws=Jws, opts=Opts}, Challenges) -> 646 | 647 | AuthzRet = letsencrypt_api:authorization(Uri, Key, Jws#{nonce => Nonce}, Opts), 648 | %io:format("authzret= ~p~n", [AuthzRet]), 649 | 650 | case AuthzRet of 651 | %{error, Err, Nonce2} -> 652 | % {error, Err, Nonce2}; 653 | 654 | {ok, Authz, _, Nonce2} -> 655 | % get challenge 656 | % map( 657 | % type, 658 | % url, 659 | % token 660 | % ) 661 | [Challenge] = lists:filter(fun(C) -> 662 | maps:get(<<"type">>, C, error) =:= bin(ChallengeType) 663 | end, 664 | maps:get(<<"challenges">>, Authz) 665 | ), 666 | 667 | authz_step1(T, ChallengeType, State#state{nonce=Nonce2}, 668 | Challenges#{Uri => Challenge}) 669 | end. 670 | 671 | % authz_step2(Challenges, State). 672 | % 673 | % 2d part Authorization, executed after challenge initialization. 674 | % Notify acme server we're good to proceed to challenges. 675 | % 676 | -spec authz_step2(list(binary()), state()) -> {ok, nonce()} | {error, binary(), nonce()}. 677 | authz_step2([], #state{nonce=Nonce}) -> 678 | {ok, Nonce}; 679 | authz_step2([{_Uri, Challenge}|T], State=#state{nonce=Nonce, key=Key, jws=Jws, opts=Opts}) -> 680 | {ok, _, _, Nonce2 } = letsencrypt_api:challenge(Challenge, Key, Jws#{nonce => Nonce}, Opts), 681 | authz_step2(T, State#state{nonce=Nonce2}). 682 | 683 | % challenge_init(Mode, State, ChallengeType, Challenges) 684 | % 685 | % Initialize local configuration to serve given challenge 686 | % Depends on challenge type & mode 687 | % 688 | % TODO: ChallengeType is included in Challenges (<<"type">> key). To refactor 689 | % 690 | -spec challenge_init(mode(), state(), challenge_type(), map()) -> ok. 691 | challenge_init(webroot, #state{webroot_path=WPath, account_key=AccntKey}, 'http-01', Challenges) -> 692 | maps:fold( 693 | fun(_K, #{<<"token">> := Token}, _Acc) -> 694 | Thumbprint = letsencrypt_jws:keyauth(AccntKey, Token), 695 | file:write_file(<<(bin(WPath))/binary, $/, ?WEBROOT_CHALLENGE_PATH/binary, $/, Token/binary>>, 696 | Thumbprint) 697 | end, 698 | 0, Challenges 699 | ); 700 | challenge_init(slave, _, _, _) -> 701 | ok; 702 | challenge_init(standalone, #state{port=Port, domain=Domain, account_key=AccntKey}, ChallengeType, Challenges) -> 703 | %io:format("init standalone challenge for ~p~n", [ChallengeType]), 704 | {ok, _} = case ChallengeType of 705 | 'http-01' -> 706 | % elli webserver callback args is: 707 | % #{Domain => #{ 708 | % Token => Thumbprint, 709 | % ... 710 | % }} 711 | % 712 | Thumbprints = maps:from_list(lists:map( 713 | fun(#{<<"token">> := Token}) -> 714 | {Token, letsencrypt_jws:keyauth(AccntKey, Token)} 715 | end, maps:values(Challenges) 716 | )), 717 | elli:start_link([ 718 | {name , {local, letsencrypt_elli_listener}}, 719 | {callback, letsencrypt_elli_handler}, 720 | {callback_args, [#{Domain => Thumbprints}]}, 721 | {port , Port} 722 | ]); 723 | 724 | _ -> 725 | io:format("standalone mode: unknown ~p challenge type~n", [ChallengeType]) 726 | % TODO 727 | %'tls-sni-01' -> 728 | end, 729 | 730 | ok. 731 | 732 | % challenge_destroy(Mode, State) 733 | % 734 | % cleanup challenge context after it has been fullfilled (with success or not). 735 | % 'webroot' mode: 736 | % - delete token file 737 | % 'standalone' mode: 738 | % - stop internal webserver 739 | % 'slave' mode: 740 | % - _nothing to do_ 741 | % 742 | % returns: 'ok' 743 | % 744 | -spec challenge_destroy(mode(), state()) -> ok. 745 | challenge_destroy(webroot, #state{webroot_path=WPath, challenges=Challenges}) -> 746 | maps:fold(fun(_K, #{<<"token">> := Token}, _) -> 747 | file:delete(<<(bin(WPath))/binary, $/, ?WEBROOT_CHALLENGE_PATH/binary, $/, Token/binary>>) 748 | end, 0, Challenges), 749 | ok; 750 | challenge_destroy(standalone, _) -> 751 | % stop http server 752 | elli:stop(letsencrypt_elli_listener), 753 | ok; 754 | challenge_destroy(slave, _) -> 755 | ok. 756 | 757 | 758 | -------------------------------------------------------------------------------- /src/letsencrypt_api.erl: -------------------------------------------------------------------------------- 1 | %% Copyright 2015-2020 Guillaume Bour 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(letsencrypt_api). 16 | -author("Guillaume Bour "). 17 | 18 | -export([directory/2, nonce/2, account/4, order/5, order/4, authorization/4, challenge/4, 19 | finalize/5, certificate/4, status/1]). 20 | 21 | -import(letsencrypt_utils, [str/1]). 22 | 23 | 24 | -ifdef(TEST). 25 | -define(STAGING_API_URL, <<"https://127.0.0.1:14000/dir">>). 26 | -define(DEFAULT_API_URL, <<"">>). 27 | -else. 28 | -define(STAGING_API_URL, <<"https://acme-staging-v02.api.letsencrypt.org/directory">>). 29 | -define(DEFAULT_API_URL, <<"https://acme-v02.api.letsencrypt.org/directory">>). 30 | -endif. 31 | 32 | -ifdef(DEBUG). 33 | -define(debug(Fmt, Args), io:format(Fmt, Args)). 34 | -else. 35 | -define(debug(Fmt, Args), ok). 36 | -endif. 37 | 38 | -spec status(binary()) -> atom(). 39 | status(<<"pending">>) -> pending; 40 | status(<<"processing">>) -> processing; 41 | status(<<"valid">>) -> valid; 42 | status(<<"invalid">>) -> invalid; 43 | status(<<"revoked">>) -> revoked; 44 | status(_Status) -> 45 | io:format("unknown status: ~p~n", [_Status]), 46 | unknown. 47 | 48 | %% PRIVATE 49 | 50 | % tcpconn({Prototype, Hostname/IP, Port}) 51 | % 52 | % returns: {ok, ConnID} 53 | % 54 | % Opened connections are stored in `conns` ets. If a connection to the given Host:Port 55 | % is already opened, returns it, either open a new connection. 56 | % 57 | % TODO: checks connection is still alive (ping ?) 58 | -spec tcpconn({http|https, string(), integer()}) -> {ok, pid()}. 59 | tcpconn(Key={Proto, Host, Port}) -> 60 | case ets:info(conns) of 61 | % does not exists 62 | undefined -> ets:new(conns, [set, named_table]); 63 | _ -> ok 64 | end, 65 | 66 | case ets:lookup(conns, Key) of 67 | % not found 68 | [] -> 69 | %TODO: handle connection failures 70 | {ok, Conn} = shotgun:open(Host, Port, Proto), 71 | ets:insert(conns, {Key, Conn}), 72 | {ok, Conn}; 73 | [{_, Conn}] -> 74 | {ok, Conn} 75 | end. 76 | 77 | % decode(Option, Result) 78 | % 79 | % Decodes http body as json if asked, or return as if. 80 | % 81 | % returns: 82 | % {ok, Result} with added json structure if required 83 | % 84 | -spec decode(map(), map()) -> {ok, map()}. 85 | decode(#{json := true}, Response=#{body := Body}) -> 86 | Payload = jiffy:decode(Body, [return_maps, use_nil]), 87 | {ok, Response#{json => Payload}}; 88 | decode(_, Response) -> 89 | {ok, Response}. 90 | 91 | % request(get|post, Uri, Headers, Content, Options) 92 | % 93 | % Query Uri (get or post) and return results. 94 | % 95 | % returns: 96 | % {ok, #{status_coe, body, headers}} :: query succeed 97 | % {error, invalid_method} :: Method MUST be either 'get' or 'post' 98 | % {error, term()} :: query failed 99 | % 100 | % TODO: is 'application/jose+json' content type always required ? 101 | % (check acme documentation) 102 | -spec request(get|post, string()|binary(), map(), nil|binary(), map()) -> 103 | shotgun:result()|{error, invalid_method}. 104 | request(Method, Uri, Headers, Content, Opts=#{netopts := Netopts}) -> 105 | {ok, {Proto, _, Host, Port, Path, _}} = http_uri:parse(str(Uri)), 106 | 107 | Headers2 = Headers#{<<"content-type">> => <<"application/jose+json">>}, 108 | 109 | % we want to reuse connection if exists 110 | {ok, Conn} = tcpconn({Proto, Host, Port}), 111 | 112 | Result = case Method of 113 | get -> shotgun:get(Conn, Path, Headers2, Netopts); 114 | post -> shotgun:post(Conn, Path, Headers2, Content, Netopts); 115 | _ -> {error, invalid_method} 116 | end, 117 | 118 | ?debug("~p(~p) => ~p~n", [Method, Uri, Result]), 119 | case Result of 120 | {ok, Response=#{headers := RHeaders}} -> 121 | R = Response#{ 122 | nonce => proplists:get_value(<<"replay-nonce">>, RHeaders, nil), 123 | location => proplists:get_value(<<"location">>, RHeaders, nil) 124 | }, 125 | decode(Opts, R); 126 | _ -> Result 127 | end. 128 | 129 | %% 130 | %% PUBLIC FUNCTIONS 131 | %% 132 | 133 | % directory(Environment, Options) 134 | % 135 | % Get directory map listing all acme protocol urls. 136 | % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.1 137 | % 138 | % returns: 139 | % {ok, Directory} where Directory is a map containing protocol urls 140 | % 141 | -spec directory(default|staging, map()) -> {ok, map()}. 142 | directory(Env, Opts) -> 143 | Uri = case Env of 144 | staging -> ?STAGING_API_URL; 145 | _ -> ?DEFAULT_API_URL 146 | end, 147 | ?debug("Getting directory at ~p~n", [Uri]), 148 | 149 | {ok, #{json := Directory}} = request(get, Uri, #{}, nil, Opts#{json => true}), 150 | {ok, Directory}. 151 | 152 | % nonce(Directory, Options) 153 | % 154 | % Get a fresh nonce. 155 | % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.2 156 | % 157 | % returns: 158 | % {ok, Nonce} 159 | % 160 | -spec nonce(map(), map()) -> {ok, binary()}. 161 | nonce(#{<<"newNonce">> := Uri}, Opts) -> 162 | {ok, #{nonce := Nonce}} = request(get, Uri, #{}, nil, Opts), 163 | {ok, Nonce}. 164 | 165 | % account(Directory, Key, Jws, Opts) 166 | % 167 | % Request new account. 168 | % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3.1 169 | % 170 | % returns: 171 | % {ok, Response, Location, Nonce} 172 | % - Response is json (decoded as map) 173 | % - Location is create account url 174 | % - Nonce is a new valid replay-nonce 175 | % 176 | % NOTE: tos are automatically agreed, this should not be the case 177 | % TODO: checks 201 Created response 178 | % 179 | -spec account(map(), binary(), map(), map()) -> {ok, map(), binary(), binary()}. 180 | account(#{<<"newAccount">> := Uri}, Key, Jws, Opts) -> 181 | Payload = #{ 182 | termsOfServiceAgreed => true, 183 | contact => [] 184 | }, 185 | Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, Payload), 186 | 187 | {ok, #{ 188 | json := Resp, 189 | location := Location, 190 | nonce := Nonce 191 | }} = request(post, Uri, #{}, Req, Opts#{json => true}), 192 | {ok, Resp, Location, Nonce}. 193 | 194 | % order(Directory, Domain, Key, Jws, Opts) 195 | % 196 | % Request new order. 197 | % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4 198 | % 199 | % returns: 200 | % {ok, Response, Location, Nonce} 201 | % - Response is json (decoded as map) 202 | % - Location is create account url 203 | % - Nonce is a new valid replay-nonce 204 | % 205 | % TODO: support multiple domains 206 | % checks 201 created 207 | % 208 | -spec order(map(), binary(), binary(), map(), map()) -> {ok, map(), binary(), binary()}. 209 | order(#{<<"newOrder">> := Uri}, Domain, Key, Jws, Opts) -> 210 | Payload = #{ 211 | identifiers => [#{ 212 | type => dns, 213 | value => Domain 214 | }] 215 | }, 216 | Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, Payload), 217 | 218 | {ok, #{ 219 | json := Resp, 220 | location := Location, 221 | nonce := Nonce 222 | }} = request(post, Uri, #{}, Req, Opts#{json => true}), 223 | {ok, Resp, Location, Nonce}. 224 | 225 | % order(Uri, Key, Jws, Opts) 226 | % 227 | % Get order state. 228 | % 229 | order(Uri, Key, Jws, Opts) -> 230 | % POST-as-GET = no payload 231 | Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, empty), 232 | 233 | {ok, #{ 234 | json := Resp, 235 | location := Location, 236 | nonce := Nonce 237 | }} = request(post, Uri, #{}, Req, Opts#{json=> true}), 238 | 239 | {ok, Resp, Location, Nonce}. 240 | 241 | % authorization(Uri, Key, Jws, Opts) 242 | % 243 | % Request authorization for given identifier. 244 | % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4.1 245 | % 246 | % returns: 247 | % {ok, Response, Location, Nonce} 248 | % - Response is json (decoded as map) 249 | % - Location is create account url 250 | % - Nonce is a new valid replay-nonce 251 | % 252 | % 253 | -spec authorization(binary(), binary(), map(), map()) -> {ok, map(), binary(), binary()}. 254 | authorization(Uri, Key, Jws, Opts) -> 255 | % POST-as-GET = no payload 256 | Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, empty), 257 | 258 | {ok, #{ 259 | json := Resp, 260 | location := Location, 261 | nonce := Nonce 262 | }} = request(post, Uri, #{}, Req, Opts#{json=> true}), 263 | {ok, Resp, Location, Nonce}. 264 | 265 | % challenge(Challenge, Key, Jws, Opts) 266 | % 267 | % Notifies acme server we are ready for challenge validation. 268 | % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1 269 | % 270 | % returns: 271 | % {ok, Response, Location, Nonce} 272 | % - Response is json (decoded as map) 273 | % - Location is create account url 274 | % - Nonce is a new valid replay-nonce 275 | % 276 | -spec challenge(map(), binary(), map(), map()) -> {ok, map(), binary(), binary()}. 277 | challenge(#{<<"url">> := Uri}, Key, Jws, Opts) -> 278 | % POST-as-GET = no payload 279 | Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, #{}), 280 | 281 | {ok, #{ 282 | json := Resp, 283 | location := Location, 284 | nonce := Nonce 285 | }} = request(post, Uri, #{}, Req, Opts#{json => true}), 286 | {ok, Resp, Location, Nonce}. 287 | 288 | % finalize(Order, Csr, Key, Jws, Opts) 289 | % 290 | % Finalize order once a challenge has been validated. 291 | % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4 292 | % 293 | % returns: 294 | % 295 | % finalize order 296 | -spec finalize(map(), binary(), letsencrypt:ssl_privatekey(), map(), map()) -> {ok, map(), binary(), binary()}. 297 | finalize(#{<<"finalize">> := Uri}, Csr, Key, Jws, Opts) -> 298 | Payload = #{ 299 | csr => Csr 300 | }, 301 | 302 | Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, Payload), 303 | 304 | {ok, #{ 305 | json := Resp, 306 | location := Location, 307 | nonce := Nonce 308 | }} = request(post, Uri, #{}, Req, Opts#{json => true}), 309 | {ok, Resp, Location, Nonce}. 310 | 311 | % certificate(Order, Key, Jws, Opts) 312 | % 313 | % Download certificate (for finalized order. 314 | % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4.2 315 | % 316 | % returns: 317 | % {ok, Cert} 318 | % 319 | -spec certificate(map(), letsencrypt:ssl_privatekey(), map(), map()) -> {ok, binary()}. 320 | certificate(#{<<"certificate">> := Uri}, Key, Jws, Opts) -> 321 | % POST-as-GET = no payload 322 | Req = letsencrypt_jws:encode(Key, Jws#{url => Uri}, empty), 323 | 324 | {ok, #{ 325 | body := Cert 326 | }} = request(post, Uri, #{}, Req, Opts), 327 | {ok, Cert}. 328 | 329 | -------------------------------------------------------------------------------- /src/letsencrypt_elli_handler.erl: -------------------------------------------------------------------------------- 1 | %% Copyright 2015-2020 Guillaume Bour 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(letsencrypt_elli_handler). 16 | -behaviour(elli_handler). 17 | 18 | -include_lib("elli/include/elli.hrl"). 19 | -export([handle/2, handle_event/3]). 20 | 21 | 22 | handle(Req, Args) -> 23 | %io:format("Elli: ~p~n~p~n", [Req, Args]), 24 | handle(elli_request:method(Req), elli_request:path(Req), Req, Args). 25 | 26 | 27 | handle('GET', [<<".well-known">>, <<"acme-challenge">>, Token], Req, [Thumbprints]) -> 28 | %NOTE: when testing on travis with local boulder instance, Host header may contain port number 29 | % I dunno if it can happens againts production boulder, but this line filters it out 30 | [Host|_] = binary:split(elli_request:get_header(<<"Host">>, Req, <<>>), <<":">>), 31 | %io:format("ELLI: host= ~p~n", [Host]), 32 | 33 | case maps:get(Host, Thumbprints, undefined) of 34 | #{Token := Thumbprint} -> 35 | %io:format("match: ~p -> ~p~n", [Token, Thumbprint]), 36 | {200, [{<<"Content-Type">>, <<"text/plain">>}], Thumbprint}; 37 | 38 | _X -> 39 | %io:format("nomatch: ~p -> ~p~n", [Token, _X]), 40 | {404, [], <<"Not Found">>} 41 | end. 42 | 43 | 44 | % request events. Unused 45 | handle_event(_, _, _) -> 46 | ok. 47 | -------------------------------------------------------------------------------- /src/letsencrypt_jws.erl: -------------------------------------------------------------------------------- 1 | %% Copyright 2015-2020 Guillaume Bour 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(letsencrypt_jws). 16 | -author("Guillaume Bour "). 17 | 18 | -export([init/1, encode/3, keyauth/2]). 19 | 20 | 21 | % init(Key) 22 | % 23 | % Initialize a RSA JWS with given private key. 24 | % 25 | % returns: 26 | % JWS 27 | -spec init(letsencrypt:ssl_privatekey()) -> letsencrypt:jws(). 28 | init(#{b64 := {N,E}}) -> 29 | #{ 30 | alg => 'RS256', 31 | jwk => #{ 32 | kty => 'RSA', 33 | <<"n">> => N, 34 | <<"e">> => E 35 | }, 36 | nonce => undefined 37 | }. 38 | 39 | % encode(Key, Jws, Payload) 40 | % 41 | % Build Jws body. 42 | % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-6.2 43 | % 44 | % returns: 45 | % JwsBody 46 | % 47 | -spec encode(letsencrypt:ssl_privatekey(), letsencrypt:jws(), map()|empty) -> binary(). 48 | encode(#{raw := RSAKey}, Jws, Content) -> 49 | Protected = letsencrypt_utils:b64encode(jiffy:encode(Jws)), 50 | Payload = case Content of 51 | % for POST-as-GET queries, payload is just an empty string 52 | empty -> <<"">>; 53 | _ -> letsencrypt_utils:b64encode(jiffy:encode(Content)) 54 | end, 55 | 56 | Sign = crypto:sign(rsa, sha256, <>, RSAKey), 57 | Sign2 = letsencrypt_utils:b64encode(Sign), 58 | 59 | jiffy:encode({[ 60 | %{header, {[]}}, 61 | {protected, Protected}, 62 | {payload , Payload}, 63 | {signature, Sign2} 64 | ]}). 65 | 66 | % keyauth(Key, Token) 67 | % 68 | % Build acme key authorization. 69 | % ref: https://www.rfc-editor.org/rfc/rfc8555.html#section-8.1 70 | % 71 | % returns: 72 | % KeyAuthorization 73 | % 74 | keyauth(#{<<"e">> := E, <<"n">> := N, <<"kty">> := Kty}, Token) -> 75 | Thumbprint = jiffy:encode({[ 76 | {e, E}, 77 | {kty, Kty}, 78 | {n, N} 79 | ]}, [force_utf8]), 80 | 81 | <>. 82 | -------------------------------------------------------------------------------- /src/letsencrypt_ssl.erl: -------------------------------------------------------------------------------- 1 | %% Copyright 2015-2020 Guillaume Bour 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(letsencrypt_ssl). 16 | -author("Guillaume Bour "). 17 | 18 | -export([private_key/2, cert_request/3, cert_autosigned/3, certificate/3]). 19 | 20 | -include_lib("public_key/include/public_key.hrl"). 21 | -import(letsencrypt_utils, [bin/1]). 22 | 23 | % create key 24 | -spec private_key(undefined|{new, string()}|string(), string()) -> letsencrypt:ssl_privatekey(). 25 | private_key(undefined, CertsPath) -> 26 | private_key({new, "letsencrypt.key"}, CertsPath); 27 | 28 | private_key({new, KeyFile}, CertsPath) -> 29 | FileName = CertsPath++"/"++KeyFile, 30 | Cmd = "openssl genrsa -out '"++FileName++"' 2048", 31 | _R = os:cmd(Cmd), 32 | 33 | private_key(FileName, CertsPath); 34 | 35 | private_key(KeyFile, _) -> 36 | {ok, Pem} = file:read_file(KeyFile), 37 | [Key] = public_key:pem_decode(Pem), 38 | #'RSAPrivateKey'{modulus=N, publicExponent=E, privateExponent=D} = public_key:pem_entry_decode(Key), 39 | 40 | #{ 41 | raw => [E,N,D], 42 | b64 => { 43 | letsencrypt_utils:b64encode(binary:encode_unsigned(N)), 44 | letsencrypt_utils:b64encode(binary:encode_unsigned(E)) 45 | }, 46 | file => KeyFile 47 | }. 48 | 49 | 50 | -spec cert_request(string(), string(), list(string())) -> letsencrypt:ssl_csr(). 51 | cert_request(Domain, CertsPath, SANs) -> 52 | KeyFile = CertsPath ++ "/" ++ Domain ++ ".key", 53 | CertFile = CertsPath ++ "/" ++ Domain ++ ".csr", 54 | {ok, CertFile} = mkcert(request, Domain, CertFile, KeyFile, SANs), 55 | %io:format("CSR ~p~n", [CertFile]), 56 | 57 | case file:read_file(CertFile) of 58 | {ok, RawCsr} -> 59 | [{'CertificationRequest', Csr, not_encrypted}] = public_key:pem_decode(RawCsr), 60 | 61 | %io:format("csr= ~p~n", [Csr]), 62 | letsencrypt_utils:b64encode(Csr); 63 | {error, enoent} -> 64 | io:format("cert_request: cert file ~p not found~n", [CertFile]), 65 | throw(file_not_found); 66 | {error, Err} -> 67 | io:format("cert_request: unknown error ~p~n", [Err]), 68 | throw(unknown_error) 69 | end. 70 | 71 | 72 | % create temporary (1 day) certificate with subjectAlternativeName 73 | % used for tls-sni-01 challenge 74 | -spec cert_autosigned(string(), string(), list(string())) -> {ok, string()}. 75 | cert_autosigned(Domain, KeyFile, SANs) -> 76 | CertFile = "/tmp/"++Domain++"-tlssni-autosigned.pem", 77 | mkcert(request, Domain, CertFile, KeyFile, SANs). 78 | 79 | 80 | -spec mkcert(request|autosigned, string(), string(), string(), list(string())) -> {ok, string()}. 81 | mkcert(request, Domain, OutName, Keyfile, SANs) -> 82 | AltNames = lists:foldl(fun(San, Acc) -> 83 | <> 84 | end, <<"subjectAltName=DNS:", (bin(Domain))/binary>>, SANs), 85 | Cmd = io_lib:format("openssl req -new -key '~s' -out '~s' -subj '/CN=~s' -addext '~s'", 86 | [Keyfile, OutName, Domain, AltNames]), 87 | 88 | _Status = os:cmd(Cmd), 89 | %io:format("mkcert(request):~p => ~p~n", [lists:flatten(Cmd), _Status]), 90 | {ok, OutName}. 91 | 92 | % domain certificate only 93 | certificate(Domain, DomainCert, CertsPath) -> 94 | FileName = CertsPath++"/"++Domain++".crt", 95 | %io:format("domain cert: ~p~nintermediate: ~p~n", [DomainCert, IntermediateCert]), 96 | %io:format("writing final certificate to ~p~n", [FileName]), 97 | 98 | file:write_file(FileName, DomainCert), 99 | FileName. 100 | 101 | -------------------------------------------------------------------------------- /src/letsencrypt_utils.erl: -------------------------------------------------------------------------------- 1 | %% Copyright 2015-2020 Guillaume Bour 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(letsencrypt_utils). 16 | -author("Guillaume Bour "). 17 | 18 | -export([b64encode/1, hexdigest/1, hashdigest/2, bin/1, str/1]). 19 | 20 | -type character() :: integer(). 21 | 22 | -spec b64encode(string()|binary()) -> binary(). 23 | b64encode(X) -> 24 | Base64 = base64:encode(X), 25 | << <<(encode_byte(B)):8>> || <> <= Base64, B =/= $= >>. 26 | 27 | 28 | -spec encode_byte(character()) -> character(). 29 | encode_byte($+) -> $-; 30 | encode_byte($/) -> $_; 31 | encode_byte(B) -> B. 32 | 33 | 34 | -spec hexdigest(string()|binary()) -> binary(). 35 | hexdigest(X) -> 36 | << <<(hex(H)),(hex(L))>> || <> <= X >>. 37 | 38 | hex(C) when C < 10 -> $0 + C; 39 | hex(C) -> $a + C - 10. 40 | 41 | % returns hexadecimal digest of SHA256 hashed content 42 | -spec hashdigest(sha256, binary()) -> binary(). 43 | hashdigest(sha256, Content) -> 44 | hexdigest(crypto:hash(sha256, Content)). 45 | 46 | -spec bin(binary()|string()) -> binary(). 47 | bin(X) when is_binary(X) -> 48 | X; 49 | bin(X) when is_list(X) -> 50 | list_to_binary(X); 51 | bin(X) when is_atom(X) -> 52 | erlang:atom_to_binary(X, utf8); 53 | bin(_X) -> 54 | throw(invalid). 55 | 56 | 57 | -spec str(binary()) -> string(). 58 | str(X) when is_binary(X) -> 59 | binary_to_list(X); 60 | str(X) when is_integer(X) -> 61 | integer_to_list(X); 62 | str(X) when is_list(X) -> 63 | X; 64 | str(_X) -> 65 | throw(invalid). 66 | 67 | -------------------------------------------------------------------------------- /test/letsencrypt_SUITE.erl: -------------------------------------------------------------------------------- 1 | %% Copyright 2015-2020 Guillaume Bour 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(letsencrypt_SUITE). 16 | -compile([export_all]). 17 | 18 | -include_lib("common_test/include/ct.hrl"). 19 | -include_lib("eunit/include/eunit.hrl"). 20 | 21 | -include_lib("public_key/include/public_key.hrl"). 22 | 23 | -define(DEBUG(Str), ct:log(default, 50, Str++"~n", [])). 24 | -define(DEBUG(Fmt, Args), ct:log(default, 50, Fmt++"~n", Args)). 25 | 26 | -define(HOSTNAME, <<"le.wtf">>). 27 | -define(HOSTNAME2, <<"le2.wtf">>). 28 | -define(FAKE_CA, "Pebble Intermediate CA 60eec7"). 29 | 30 | generate_groups([], Tests) -> 31 | Tests; 32 | generate_groups([H|T], Tests) -> 33 | Sub = generate_groups(T, Tests), 34 | 35 | [ {Item, [], Sub} || Item <- H ]. 36 | 37 | groups() -> 38 | % we build a test matrix (combining all matrix items) 39 | Tests = [test_standalone, test_slave, test_webroot], 40 | Matrix = [ 41 | ['dft-challenge', 'http-01'] % challenge type 42 | ,['dft-sync', 'async', 'sync'] % sync/async 43 | ,[unidomain] %,[unidomain, san] % san or not 44 | ], 45 | 46 | Groups = generate_groups(Matrix, Tests), 47 | io:format("groups = ~p~n", [Groups]), 48 | [ 49 | {matrix, [], Groups} 50 | %,{'tls-sni-01', [], [test_standalone]} 51 | ]. 52 | 53 | % {simple, [], [ 54 | % test_standalone 55 | % ,test_slave 56 | % ,test_webroot 57 | % ]}, 58 | % {san, [], [ 59 | % test_standalone 60 | % ,test_slave 61 | % ,test_webroot 62 | % ]}, 63 | % {tlssni, [], [ 64 | % test_standalone 65 | % }] 66 | % ]. 67 | 68 | all() -> 69 | [ 70 | {group, matrix} 71 | %,{group, 'tls-sni-01'} 72 | ]. 73 | 74 | 75 | init_per_suite(Config) -> 76 | application:ensure_all_started(letsencrypt), 77 | [{opts, #{}}]. 78 | 79 | end_per_suite(Config) -> 80 | application:stop(letsencrypt), 81 | ok. 82 | 83 | setopt(Config, Kv) -> 84 | Opts = proplists:get_value(opts, Config), 85 | lists:keyreplace(opts, 1, Config, {opts, maps:merge(Opts, Kv)}). 86 | 87 | % challenge type (default: http-01) 88 | init_per_group('dft-challenge', Config) -> 89 | [{port, 5002},{filter, 'dft-challenge'}| Config]; 90 | init_per_group('http-01', Config) -> 91 | [{port, 5002},{filter, 'http-01'}| setopt(Config, #{challenge => 'http-01'})]; 92 | init_per_group('tls-sni-01', Config) -> 93 | [{port, 5001},{filter,'tls-sni-01'}| setopt(Config, #{challenge => 'tls-sni-01', async => true})]; 94 | % sync/async 95 | init_per_group(sync, Config) -> 96 | [{filter, sync}| setopt(Config, #{async => false})]; 97 | init_per_group(async, Config) -> 98 | [{filter, async}| setopt(Config, #{async => true})]; 99 | % unidomain/san 100 | init_per_group(N=san, Config) -> 101 | [{filler, san}| setopt(Config, #{san => [?HOSTNAME2]})]; 102 | 103 | init_per_group(GroupName, Config) -> 104 | [{filter,GroupName}| Config]. 105 | 106 | end_per_group(_,_) -> 107 | ok. 108 | 109 | 110 | test_standalone(Config) -> 111 | Port = proplists:get_value(port, Config), 112 | priv_COMMON(standalone, Config, [{port,Port}]). 113 | 114 | test_slave(Config) -> 115 | Port = proplists:get_value(port, Config), 116 | %cowboy:stop_listener(my_http_listener), 117 | elli:start_link([ 118 | {name , {local, my_test_slave_listener}}, 119 | {callback, test_slave_handler}, 120 | {port , Port} 121 | ]), 122 | 123 | try priv_COMMON(slave, Config, []) of 124 | Ret -> Ret 125 | after 126 | elli:stop(my_test_slave_listener) 127 | end. 128 | 129 | test_webroot(Config) -> 130 | Port = proplists:get_value(port, Config), 131 | %cowboy:stop_listener(webroot_listener), 132 | 133 | elli:start_link([ 134 | {name , {local, my_test_webroot_listener}}, 135 | {callback, test_webroot_handler}, 136 | {port , Port} 137 | ]), 138 | 139 | try priv_COMMON(webroot, Config, [{webroot_path, "/tmp"}]) of 140 | Ret -> Ret 141 | after 142 | elli:stop(my_test_webroot_listener) 143 | end. 144 | 145 | %% 146 | %% PRIVATE 147 | %% 148 | 149 | 150 | priv_COMMON(Mode, Config, StartOpts) -> 151 | Comment = string:join(lists:map(fun erlang:atom_to_list/1, proplists:get_all_values(filter, Config)), ","), 152 | ct:comment(Comment, []), 153 | ct:print("%%% letsencrypt_SUITE <== test_"++erlang:atom_to_list(Mode)++": "++Comment), 154 | 155 | Opts = proplists:get_value(opts, Config), 156 | Async = maps:get(async, Opts, true), 157 | ?DEBUG("async: ~p, opts: ~p, startopts: ~p", [Async, Opts, StartOpts]), 158 | 159 | {ok, Pid} = letsencrypt:start([{mode, Mode}, staging, {cert_path, "/tmp"}]++StartOpts), 160 | 161 | R3 = case Async of 162 | false -> 163 | letsencrypt:make_cert(?HOSTNAME, Opts); 164 | 165 | true -> 166 | % async callback 167 | Parent = self(), 168 | C = fun(R) -> 169 | Parent ! {complete, R} 170 | end, 171 | 172 | async = letsencrypt:make_cert(?HOSTNAME, Opts#{callback => C}), 173 | receive 174 | {complete, R2} -> R2 175 | after 60000 -> {error, async_timeout} 176 | end 177 | end, 178 | 179 | letsencrypt:stop(), 180 | % checking certificate returned 181 | ?DEBUG("result: ~p", [R3]), 182 | {ok, #{cert := Cert, key := Key}} = R3, 183 | certificate_validation(Cert, ?HOSTNAME, maps:get(san, Opts, [])), 184 | 185 | ok. 186 | 187 | certificate_validation(CertFile, Domain, SAN) -> 188 | {ok, File} = file:read_file(CertFile), 189 | [{'Certificate',Cert,_}|_] = public_key:pem_decode(File), % 2d certificate is letsencrypt intermediate's one 190 | 191 | #'OTPCertificate'{tbsCertificate = #'OTPTBSCertificate'{ 192 | issuer = Issuer, 193 | validity = #'Validity'{notBefore = Start, notAfter= End}, 194 | subject = Subject, 195 | extensions = Exts 196 | }} = public_key:pkix_decode_cert(Cert, otp), 197 | 198 | ?DEBUG("== certificate informations ==~n"++ 199 | " > subject: ~p~n"++ 200 | " > issuer : ~p~n"++ 201 | " > start/stop: ~p/~p~n" ++ 202 | " > altNames: ~p~n", 203 | [rdnSeq(Subject, ?'id-at-commonName'), rdnSeq(Issuer, ?'id-at-commonName'), 204 | to_date(Start), to_date(End), exten(Exts, ?'id-ce-subjectAltName')]), 205 | 206 | % performing match tests 207 | startswith(rdnSeq(Issuer , ?'id-at-commonName'), ?FAKE_CA, "wrong issuer (~p =:= ~p)"), 208 | match(rdnSeq(Subject, ?'id-at-commonName'), erlang:binary_to_list(Domain), "wrong CN (~p =:= ~p)"), 209 | 210 | SAN2 = [ erlang:binary_to_list(X) || X <- [Domain|SAN] ], 211 | match(exten(Exts, ?'id-ce-subjectAltName'), SAN2, "wrong SAN (~p =:= ~p)"), 212 | 213 | % certificate validity = 5 years 214 | match(add_years(to_date(Start), 5), to_date(End), "wrong certificate validity (~p =:= ~p)"), 215 | 216 | ok. 217 | 218 | rdnSeq({rdnSequence, Seq}, Match) -> 219 | rdnSeq(Seq, Match); 220 | rdnSeq([[{'AttributeTypeAndValue', Match, Result}]|_], Match) -> 221 | str(Result); 222 | rdnSeq([H|T], Match) -> 223 | rdnSeq(T, Match); 224 | rdnSeq([], _) -> 225 | undefined. 226 | 227 | exten([], Match) -> 228 | undefined; 229 | exten([#'Extension'{extnID = Match, extnValue = Values}|_], Match) -> 230 | [ str(DNS) || DNS <- Values]; 231 | exten([H|T], Match) -> 232 | exten(T, Match). 233 | 234 | str({printableString, Str}) -> 235 | Str; 236 | str({utf8String, Str}) -> 237 | erlang:binary_to_list(Str); 238 | str({dNSName, Str}) -> 239 | Str. 240 | 241 | to_date({utcTime, Date}) -> 242 | case re:run(Date, "(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})Z",[{capture,all_but_first,list}]) of 243 | {match, Matches} -> 244 | [Y,M,D,H,Mm,S] = lists:map(fun(X) -> erlang:list_to_integer(X) end, Matches), 245 | {{2000+Y, M, D}, {H, Mm, S}}; 246 | 247 | _ -> error 248 | end. 249 | 250 | add_days({Date,Time}, Days) -> 251 | { 252 | calendar:gregorian_days_to_date( 253 | calendar:date_to_gregorian_days(Date) + Days), 254 | Time 255 | }. 256 | 257 | add_years({{Year,Month,Days}, Time}, Years) -> 258 | {{Year+Years,Month,Days}, Time}. 259 | 260 | match(X, Y, Msg) -> 261 | case X =:= Y of 262 | false -> 263 | ?DEBUG(Msg, [X,Y]), 264 | throw({'match-exception', lists:flatten(io_lib:format(Msg, [X,Y]))}); 265 | 266 | _ -> true 267 | end. 268 | 269 | startswith(X, Y, Msg) -> 270 | case string:slice(X, 0, string:len(Y)) of 271 | false -> 272 | ?DEBUG(Msg, [X,Y]), 273 | throw({'match-exception', lists:flatten(io_lib:format(Msg, [X,Y]))}); 274 | 275 | _ -> true 276 | end. 277 | -------------------------------------------------------------------------------- /test/test_slave_handler.erl: -------------------------------------------------------------------------------- 1 | %% Copyright 2015-2020 Guillaume Bour 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(test_slave_handler). 16 | -behaviour(elli_handler). 17 | 18 | -include_lib("elli/include/elli.hrl"). 19 | -export([handle/2, handle_event/3]). 20 | 21 | 22 | handle(Req, _Args) -> 23 | [<<".well-known">>, <<"acme-challenge">>, Token] = elli_request:path(Req), 24 | [Host|_] = binary:split(elli_request:get_header(<<"Host">>, Req, <<>>), <<":">>), 25 | Thumbprints = letsencrypt:get_challenge(), 26 | io:format("SLAVE:handle: req=~p, host= ~p, thumbprints= ~p~n", [Req, Host, Thumbprints]), 27 | 28 | case maps:get(Token, Thumbprints, nil) of 29 | Thumbprint -> 30 | io:format("200: ~p~n", [Thumbprint]), 31 | {200, [{<<"Content-Type">>, <<"text/plain">>}], Thumbprint}; 32 | 33 | _ -> 34 | io:format("404~n", []), 35 | {404, [], <<"Not Found">>} 36 | end. 37 | 38 | % request events. Unused 39 | handle_event(_, _, _) -> 40 | ok. 41 | -------------------------------------------------------------------------------- /test/test_webroot_handler.erl: -------------------------------------------------------------------------------- 1 | 2 | -module(test_webroot_handler). 3 | -behaviour(elli_handler). 4 | 5 | -include_lib("elli/include/elli.hrl"). 6 | -export([handle/2, handle_event/3]). 7 | 8 | 9 | handle(Req, _Args) -> 10 | Path = elli_request:raw_path(Req), 11 | File = <<"/tmp", Path/binary>>, 12 | ct:pal(info, "webroot_handler: reading ~p file", [File]), 13 | 14 | case file:read_file(File) of 15 | {ok, Content} -> 16 | {200, [{<<"Content-Type">>, <<"text/plain">>}], Content}; 17 | 18 | _ -> 19 | {404, [], <<"Not Found">>} 20 | end. 21 | 22 | 23 | % request events. Unused 24 | handle_event(_, _, _) -> 25 | ok. 26 | -------------------------------------------------------------------------------- /tools/showcert.escript: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env escript 2 | %% -*- erlang -*- 3 | %%! -smp enable -sname showcert debug verbose 4 | 5 | -include_lib("public_key/include/public_key.hrl"). 6 | 7 | main([CertFile]) -> 8 | {ok, File} = file:read_file(CertFile), 9 | [Cert|_] = public_key:pem_decode(File), 10 | %io:format("> ~p~n", [Cert]), 11 | 12 | {'Certificate',Cert2,not_encrypted} = Cert, 13 | {'OTPCertificate', Cert3, _, _} = public_key:pkix_decode_cert(Cert2, otp), 14 | io:format("*** RAW CERTIFICATE ***~n~p~n~n", [Cert3]), 15 | 16 | #'OTPTBSCertificate'{issuer = Issuer, validity = #'Validity'{notBefore = Start, notAfter= End}, subject = Subject, extensions = Exts} = Cert3, 17 | io:format("*** CERTIFICATE NFOs ***\n"++ 18 | "> subject : ~p~n"++ 19 | "> issuer : ~p~n"++ 20 | "> start/stop: ~p/~p (duration: ~p days)~n"++ 21 | "> altNames : ~p~n~n", 22 | [ 23 | rdnSeq(Subject, ?'id-at-commonName'), 24 | rdnSeq(Issuer , ?'id-at-commonName'), 25 | iso_8601_fmt(to_date(Start)), 26 | iso_8601_fmt(to_date(End)), 27 | diff(to_date(Start), to_date(End)), 28 | exten(Exts, ?'id-ce-subjectAltName') 29 | ]), 30 | 31 | to_date(End) =:= add_days(to_date(Start), 90), 32 | ok; 33 | 34 | main(_) -> 35 | io:format("Usage: showcert.escript certificate-file~n"), 36 | halt(1). 37 | 38 | 39 | rdnSeq({rdnSequence, Seq}, Match) -> 40 | rdnSeq(Seq, Match); 41 | rdnSeq([[{'AttributeTypeAndValue', Match, Result}]|_], Match) -> 42 | str(Result); 43 | rdnSeq([_|T], Match) -> 44 | rdnSeq(T, Match); 45 | rdnSeq([], _) -> 46 | undefined. 47 | 48 | exten([], _Match) -> 49 | undefined; 50 | exten([#'Extension'{extnID = Match, extnValue = Values}|_], Match) -> 51 | [ str(DNS) || DNS <- Values]; 52 | exten([_|T], Match) -> 53 | exten(T, Match). 54 | 55 | str({printableString, Str}) -> 56 | Str; 57 | str({utf8String, Str}) -> 58 | erlang:binary_to_list(Str); 59 | str({dNSName, Str}) -> 60 | Str. 61 | 62 | to_date({utcTime, Date}) -> 63 | case re:run(Date, "(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})Z",[{capture,all_but_first,list}]) of 64 | {match, Matches} -> 65 | [Y,M,D,H,Mm,S] = lists:map(fun(X) -> erlang:list_to_integer(X) end, Matches), 66 | {{2000+Y, M, D}, {H, Mm, S}}; 67 | 68 | _ -> error 69 | end. 70 | 71 | add_days({Date,Time}, Days) -> 72 | { 73 | calendar:gregorian_days_to_date( 74 | calendar:date_to_gregorian_days(Date) + Days), 75 | Time 76 | }. 77 | 78 | diff({Date1,_}, {Date2,_}) -> 79 | calendar:date_to_gregorian_days(Date2) - calendar:date_to_gregorian_days(Date1). 80 | 81 | iso_8601_fmt(DateTime) -> 82 | {{Year,Month,Day},{Hour,Min,Sec}} = DateTime, 83 | lists:flatten(io_lib:format("~4.10.0B-~2.10.0B-~2.10.0B ~2.10.0B:~2.10.0B:~2.10.0B", 84 | [Year, Month, Day, Hour, Min, Sec])). 85 | --------------------------------------------------------------------------------