├── .github └── workflows │ ├── ci.yml │ └── hexpm-release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── Makefile ├── README.md ├── plugins └── override_deps_versions.erl ├── rebar ├── rebar.config ├── rebar.config.script └── src ├── p1_acme.app.src ├── p1_acme.erl └── p1_acme_codec.erl /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | 7 | tests: 8 | name: Tests 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | otp: [20, 23, 24, 25, 26, 27, 28] 13 | runs-on: ubuntu-24.04 14 | container: 15 | image: public.ecr.aws/docker/library/erlang:${{ matrix.otp }} 16 | steps: 17 | - uses: actions/checkout@v3 18 | - run: make 19 | if: matrix.otp >= 24 && matrix.otp < 27 20 | - run: REBAR=rebar make 21 | if: matrix.otp < 24 22 | - run: rebar3 compile 23 | - run: rebar3 xref 24 | - run: rebar3 dialyzer 25 | - run: rebar3 eunit -v 26 | - name: Send to Coveralls 27 | if: matrix.otp == 27 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | run: | 31 | COVERALLS=true rebar3 as test coveralls send 32 | curl -v -k https://coveralls.io/webhook \ 33 | --header "Content-Type: application/json" \ 34 | --data '{"repo_name":"$GITHUB_REPOSITORY", 35 | "repo_token":"$GITHUB_TOKEN", 36 | "payload":{"build_num":$GITHUB_RUN_ID, 37 | "status":"done"}}' 38 | -------------------------------------------------------------------------------- /.github/workflows/hexpm-release.yml: -------------------------------------------------------------------------------- 1 | name: Hex 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-24.04 11 | container: 12 | image: public.ecr.aws/docker/library/erlang:latest 13 | steps: 14 | - name: Check out 15 | uses: actions/checkout@v2 16 | 17 | - name: Prepare libraries 18 | run: | 19 | sudo apt-get -qq update 20 | sudo apt-get -qq install libyaml-dev 21 | 22 | - name: Setup rebar3 hex 23 | run: | 24 | mkdir -p ~/.config/rebar3/ 25 | echo "{plugins, [rebar3_hex]}." > ~/.config/rebar3/rebar.config 26 | 27 | - run: rebar3 edoc 28 | 29 | - name: Prepare Markdown 30 | run: | 31 | echo "" >>README.md 32 | echo "## EDoc documentation" >>README.md 33 | echo "" >>README.md 34 | echo "You can check this library's " >>README.md 35 | echo "[EDoc documentation](edoc.html), " >>README.md 36 | echo "generated automatically from the source code comments." >>README.md 37 | 38 | - name: Convert Markdown to HTML 39 | uses: natescherer/markdown-to-html-with-github-style-action@v1.1.0 40 | with: 41 | path: README.md 42 | 43 | - run: | 44 | mv doc/index.html doc/edoc.html 45 | mv README.html doc/index.html 46 | 47 | - name: Publish to hex.pm 48 | run: DEBUG=1 rebar3 hex publish --repo hexpm --yes 49 | env: 50 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swo 2 | *.swp 3 | .eunit 4 | .rebar 5 | _build 6 | autom4te.cache 7 | c_src/*.d 8 | c_src/*.gcda 9 | c_src/*.gcno 10 | c_src/*.o 11 | config.log 12 | config.status 13 | deps 14 | ebin 15 | priv 16 | rebar.lock 17 | test/*.beam 18 | vars.config 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Version 1.0.26 2 | 3 | * Updating yconf to version 1.0.18. 4 | 5 | # Version 1.0.25 6 | 7 | * Updating yconf to version 1.0.17. 8 | 9 | # Version 1.0.24 10 | 11 | * Fix issues with jwk and recent jiffy 12 | 13 | # Version 1.0.23 14 | 15 | * Updating yconf to version 1.0.16. 16 | * Make compatible with OTP27 17 | 18 | # Version 1.0.21 19 | 20 | * Updating yconf to version 1.0.15. 21 | * Updating jose to version 1.11.5. 22 | 23 | # Version 1.0.20 24 | 25 | * Updating yconf to version 1.0.14. 26 | 27 | # Version 1.0.19 28 | 29 | * Updating yconf to version 1.0.13. 30 | * Fix order in which dependencies are started 31 | 32 | # Version 1.0.18 33 | 34 | * Updating jiffy to version 1.1.1 to support Mix compilation again 35 | 36 | # Version 1.0.17 37 | 38 | * Updating jiffy to version 1.1.0 to support Erlang/OTP 25.0-rc1 39 | * Copy code from eimp to use override_deps_versions only when not rebar3 40 | 41 | # Version 1.0.14 42 | 43 | * Generate documentation when publishing to hex 44 | * Updating jose to version 1.11.1. 45 | 46 | # Version 1.0.13 47 | 48 | * Updating yconf to version 1.0.12. 49 | * Switch from using Travis to Github Actions as CI 50 | 51 | # Version 1.0.12 52 | 53 | * Updating yconf to version 1.0.11. 54 | 55 | # Version 1.0.11 56 | 57 | * Updating yconf to version 1.0.10. 58 | * Add missing applicaitons to p1_acme.app 59 | 60 | # Version 1.0.10 61 | 62 | * Updating yconf to version 1.0.9. 63 | 64 | # Version 1.0.9 65 | 66 | * Updating yconf to version 1.0.8. 67 | 68 | # Version 1.0.8 69 | 70 | * Updating yconf to version 1.0.7. 71 | 72 | # Version 1.0.7 73 | 74 | * Added Travis configuration with support for Erlang/OTP 23.0 75 | * Updating jiffy to version 1.0.5. 76 | * Updating yconf to version 1.0.6. 77 | 78 | # Version 1.0.6 79 | 80 | * Updating yconf to version 1.0.5. 81 | 82 | # Version 1.0.5 83 | 84 | * Updating yconf to version 1.0.4. 85 | 86 | # Version 1.0.4 87 | 88 | * Updating yconf to version 1.0.3. 89 | * Update copyright year 90 | * Update yconf 91 | 92 | # Version 1.0.3 93 | 94 | * Updating yconf to version 071c0ba. 95 | 96 | # Version 1.0.2 97 | 98 | * Use jose 1.9.0 99 | 100 | # Version 1.0.1 101 | 102 | * Rename to p1\_acme since old name collided with existing packet on 103 | hexpm 104 | * Fix compilation when using rebar3 105 | 106 | # Version 1.0.0 107 | 108 | * Updating yconf to version 1.0.1. 109 | * Initial version 110 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | REBAR ?= ./rebar 2 | 3 | IS_REBAR3:=$(shell expr `$(REBAR) --version | awk -F '[ .]' '/rebar / {print $$2}'` '>=' 3) 4 | 5 | all: src 6 | 7 | src: 8 | $(REBAR) get-deps 9 | $(REBAR) compile 10 | 11 | clean: 12 | $(REBAR) clean 13 | 14 | distclean: clean 15 | rm -f config.status 16 | rm -f config.log 17 | rm -rf autom4te.cache 18 | rm -rf _build 19 | rm -rf deps 20 | rm -rf ebin 21 | rm -f rebar.lock 22 | rm -f test/*.beam 23 | rm -rf priv 24 | rm -f vars.config 25 | rm -f erl_crash.dump 26 | rm -f compile_commands.json 27 | rm -rf dialyzer 28 | 29 | xref: all 30 | $(REBAR) xref 31 | 32 | ifeq "$(IS_REBAR3)" "1" 33 | dialyzer: 34 | $(REBAR) dialyzer 35 | else 36 | deps := $(wildcard deps/*/ebin) 37 | 38 | dialyzer/erlang.plt: 39 | @mkdir -p dialyzer 40 | @dialyzer --build_plt --output_plt dialyzer/erlang.plt \ 41 | -o dialyzer/erlang.log --apps kernel stdlib erts ssl \ 42 | inets public_key crypto; \ 43 | status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi 44 | 45 | dialyzer/deps.plt: 46 | @mkdir -p dialyzer 47 | @dialyzer --build_plt --output_plt dialyzer/deps.plt \ 48 | -o dialyzer/deps.log $(deps); \ 49 | status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi 50 | 51 | dialyzer/p1_acme.plt: 52 | @mkdir -p dialyzer 53 | @dialyzer --build_plt --output_plt dialyzer/p1_acme.plt \ 54 | -o dialyzer/p1_acme.log ebin; \ 55 | status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi 56 | 57 | erlang_plt: dialyzer/erlang.plt 58 | @dialyzer --plt dialyzer/erlang.plt --check_plt -o dialyzer/erlang.log; \ 59 | status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi 60 | 61 | deps_plt: dialyzer/deps.plt 62 | @dialyzer --plt dialyzer/deps.plt --check_plt -o dialyzer/deps.log; \ 63 | status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi 64 | 65 | p1_acme_plt: dialyzer/p1_acme.plt 66 | @dialyzer --plt dialyzer/p1_acme.plt --check_plt -o dialyzer/p1_acme.log; \ 67 | status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi 68 | 69 | dialyzer: erlang_plt deps_plt p1_acme_plt 70 | @dialyzer --plts dialyzer/*.plt --no_check_plt \ 71 | --get_warnings -Wunmatched_returns -o dialyzer/error.log ebin; \ 72 | status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi 73 | endif 74 | 75 | check-syntax: 76 | gcc -o nul -S ${CHK_SOURCES} 77 | 78 | .PHONY: clean src xref all dialyzer erlang_plt deps_plt p1_acme_plt 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Erlang ACME client (RFC8555) 2 | 3 | This is implementation of ACME v1 protocol in ERlang language 4 | 5 | [![CI](https://github.com/processone/p1_acme/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/processone/p1_acme/actions/workflows/ci.yml) 6 | [![Coverage Status](https://coveralls.io/repos/processone/p1_acme/badge.svg?branch=master&service=github)](https://coveralls.io/github/processone/p1_acme?branch=master) 7 | [![Hex version](https://img.shields.io/hexpm/v/p1_acme.svg "Hex version")](https://hex.pm/packages/p1_acme) 8 | -------------------------------------------------------------------------------- /plugins/override_deps_versions.erl: -------------------------------------------------------------------------------- 1 | -module(override_deps_versions). 2 | -export([preprocess/2, 'pre_update-deps'/2, new_replace/1, new_replace/0]). 3 | 4 | preprocess(Config, _Dirs) -> 5 | update_deps(Config). 6 | 7 | update_deps(Config) -> 8 | LocalDeps = rebar_config:get_local(Config, deps, []), 9 | TopDeps = case rebar_config:get_xconf(Config, top_deps, []) of 10 | [] -> LocalDeps; 11 | Val -> Val 12 | end, 13 | Config2 = rebar_config:set_xconf(Config, top_deps, TopDeps), 14 | NewDeps = lists:map(fun({Name, _, _} = Dep) -> 15 | case lists:keyfind(Name, 1, TopDeps) of 16 | false -> Dep; 17 | TopDep -> TopDep 18 | end 19 | end, LocalDeps), 20 | %io:format("LD ~p~n", [LocalDeps]), 21 | %io:format("TD ~p~n", [TopDeps]), 22 | 23 | Config3 = rebar_config:set(Config2, deps, NewDeps), 24 | {ok, Config3, []}. 25 | 26 | 27 | 'pre_update-deps'(Config, _Dirs) -> 28 | {ok, Config2, _} = update_deps(Config), 29 | 30 | case code:is_loaded(old_rebar_config) of 31 | false -> 32 | {_, Beam, _} = code:get_object_code(rebar_config), 33 | NBeam = rename(Beam, old_rebar_config), 34 | code:load_binary(old_rebar_config, "blank", NBeam), 35 | replace_mod(Beam); 36 | _ -> 37 | ok 38 | end, 39 | {ok, Config2}. 40 | 41 | new_replace() -> 42 | old_rebar_config:new(). 43 | new_replace(Config) -> 44 | NC = old_rebar_config:new(Config), 45 | {ok, Conf, _} = update_deps(NC), 46 | Conf. 47 | 48 | replace_mod(Beam) -> 49 | {ok, {_, [{exports, Exports}]}} = beam_lib:chunks(Beam, [exports]), 50 | Funcs = lists:filtermap( 51 | fun({module_info, _}) -> 52 | false; 53 | ({Name, Arity}) -> 54 | Args = args(Arity), 55 | Call = case Name of 56 | new -> 57 | [erl_syntax:application( 58 | erl_syntax:abstract(override_deps_versions), 59 | erl_syntax:abstract(new_replace), 60 | Args)]; 61 | _ -> 62 | [erl_syntax:application( 63 | erl_syntax:abstract(old_rebar_config), 64 | erl_syntax:abstract(Name), 65 | Args)] 66 | end, 67 | {true, erl_syntax:function(erl_syntax:abstract(Name), 68 | [erl_syntax:clause(Args, none, 69 | Call)])} 70 | end, Exports), 71 | Forms0 = ([erl_syntax:attribute(erl_syntax:abstract(module), 72 | [erl_syntax:abstract(rebar_config)])] 73 | ++ Funcs), 74 | Forms = [erl_syntax:revert(Form) || Form <- Forms0], 75 | %io:format("--------------------------------------------------~n" 76 | % "~s~n", 77 | % [[erl_pp:form(Form) || Form <- Forms]]), 78 | {ok, Mod, Bin} = compile:forms(Forms, [report, export_all]), 79 | code:purge(rebar_config), 80 | {module, Mod} = code:load_binary(rebar_config, "mock", Bin). 81 | 82 | 83 | args(0) -> 84 | []; 85 | args(N) -> 86 | [arg(N) | args(N-1)]. 87 | 88 | arg(N) -> 89 | erl_syntax:variable(list_to_atom("A"++integer_to_list(N))). 90 | 91 | rename(BeamBin0, Name) -> 92 | BeamBin = replace_in_atab(BeamBin0, Name), 93 | update_form_size(BeamBin). 94 | 95 | %% Replace the first atom of the atom table with the new name 96 | replace_in_atab(<<"Atom", CnkSz0:32, Cnk:CnkSz0/binary, Rest/binary>>, Name) -> 97 | replace_first_atom(<<"Atom">>, Cnk, CnkSz0, Rest, latin1, Name); 98 | replace_in_atab(<<"AtU8", CnkSz0:32, Cnk:CnkSz0/binary, Rest/binary>>, Name) -> 99 | replace_first_atom(<<"AtU8">>, Cnk, CnkSz0, Rest, unicode, Name); 100 | replace_in_atab(<>, Name) -> 101 | <>. 102 | 103 | replace_first_atom(CnkName, Cnk, CnkSz0, Rest, Encoding, Name) -> 104 | <> = Cnk, 105 | NumPad0 = num_pad_bytes(CnkSz0), 106 | <<_:NumPad0/unit:8, NextCnks/binary>> = Rest, 107 | NameBin = atom_to_binary(Name, Encoding), 108 | NameSz = byte_size(NameBin), 109 | CnkSz = CnkSz0 + NameSz - NameSz0, 110 | NumPad = num_pad_bytes(CnkSz), 111 | <>. 113 | 114 | 115 | %% Calculate the number of padding bytes that have to be added for the 116 | %% BinSize to be an even multiple of ?beam_num_bytes_alignment. 117 | num_pad_bytes(BinSize) -> 118 | case 4 - (BinSize rem 4) of 119 | 4 -> 0; 120 | N -> N 121 | end. 122 | 123 | %% Update the size within the top-level form 124 | update_form_size(<<"FOR1", _OldSz:32, Rest/binary>> = Bin) -> 125 | Sz = size(Bin) - 8, 126 | <<"FOR1", Sz:32, Rest/binary>>. 127 | -------------------------------------------------------------------------------- /rebar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/processone/p1_acme/bec556d4f0643c984e2af4d10d5aafe140095c30/rebar -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author Evgeny Khramtsov 3 | %%% @copyright (C) 2002-2025 ProcessOne, SARL. All Rights Reserved. 4 | %%% 5 | %%% Licensed under the Apache License, Version 2.0 (the "License"); 6 | %%% you may not use this file except in compliance with the License. 7 | %%% You may obtain a copy of the License at 8 | %%% 9 | %%% http://www.apache.org/licenses/LICENSE-2.0 10 | %%% 11 | %%% Unless required by applicable law or agreed to in writing, software 12 | %%% distributed under the License is distributed on an "AS IS" BASIS, 13 | %%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | %%% See the License for the specific language governing permissions and 15 | %%% limitations under the License. 16 | %%% 17 | %%%------------------------------------------------------------------- 18 | 19 | {erl_opts, [debug_info, 20 | {src_dirs, ["src"]}, 21 | {i, "include"}, 22 | {if_version_below, "27", {d, 'OTP_BELOW_27'}}, 23 | {platform_define, "^(R|1|20)", deprecated_stacktrace}]}. 24 | 25 | {deps, [%{if_version_below, "27", 26 | {jiffy, "~> 1.1.1", {git, "https://github.com/davisp/jiffy.git", {tag, "1.1.1"}}}, 27 | %}, 28 | {yconf, "~> 1.0.17", {git, "https://github.com/processone/yconf.git", {tag, "1.0.18"}}}, 29 | {idna, "~> 6.0", {git, "https://github.com/benoitc/erlang-idna", {tag, "6.0.0"}}}, 30 | {base64url, "~> 1.0", {git, "https://github.com/dvv/base64url", {tag, "1.0.1"}}}, 31 | {if_version_above, "23", 32 | {jose, "~> 1.11.10", {git, "https://github.com/potatosalad/erlang-jose", {tag, "1.11.10"}}}, 33 | {jose, "1.11.1", {git, "https://github.com/potatosalad/erlang-jose", {tag, "1.11.1"}}} 34 | }]}. 35 | 36 | {cover_enabled, true}. 37 | {cover_export_enabled, true}. 38 | {coveralls_coverdata , "_build/test/cover/eunit.coverdata"}. 39 | {coveralls_service_name , "github"}. 40 | 41 | {xref_checks, [undefined_function_calls, undefined_functions, deprecated_function_calls, deprecated_functions]}. 42 | {overrides, [{del, [{erl_opts, [warnings_as_errors]}]}]}. 43 | 44 | {if_not_rebar3, {plugins, [override_deps_versions]}}. 45 | 46 | %% Compiling Jose 1.11.10 with Erlang/OTP 27.0 throws warnings on public_key deprecated functions 47 | {if_rebar3, {overrides, [{del, jose, [{erl_opts, [warnings_as_errors]}]}]}}. 48 | 49 | {profiles, [{test, [{erl_opts, [{src_dirs, ["test"]}]}]}]}. 50 | 51 | %% Local Variables: 52 | %% mode: erlang 53 | %% End: 54 | %% vim: set filetype=erlang tabstop=8: 55 | -------------------------------------------------------------------------------- /rebar.config.script: -------------------------------------------------------------------------------- 1 | %%%---------------------------------------------------------------------- 2 | %%% File : rebar.config.script 3 | %%% Author : Mickael Remond 4 | %%% Purpose : Rebar build script. Compliant with rebar and rebar3. 5 | %%% Created : 24 Nov 2015 by Mickael Remond 6 | %%% 7 | %%% Copyright (C) 2002-2025 ProcessOne, SARL. All Rights Reserved. 8 | %%% 9 | %%% Licensed under the Apache License, Version 2.0 (the "License"); 10 | %%% you may not use this file except in compliance with the License. 11 | %%% You may obtain a copy of the License at 12 | %%% 13 | %%% http://www.apache.org/licenses/LICENSE-2.0 14 | %%% 15 | %%% Unless required by applicable law or agreed to in writing, software 16 | %%% distributed under the License is distributed on an "AS IS" BASIS, 17 | %%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | %%% See the License for the specific language governing permissions and 19 | %%% limitations under the License. 20 | %%% 21 | %%%---------------------------------------------------------------------- 22 | 23 | Cfg = case file:consult(filename:join([filename:dirname(SCRIPT),"vars.config"])) of 24 | {ok, Terms} -> 25 | Terms; 26 | _Err -> 27 | [] 28 | end ++ [{cflags, "-g -O2 -Wall"}, 29 | {ldflags, "-lpthread"}, 30 | {with_gcov, "false"}], 31 | {cflags, CfgCFlags} = lists:keyfind(cflags, 1, Cfg), 32 | {ldflags, CfgLDFlags} = lists:keyfind(ldflags, 1, Cfg), 33 | {with_gcov, CfgWithGCov} = lists:keyfind(with_gcov, 1, Cfg), 34 | 35 | IsRebar3 = case application:get_key(rebar, vsn) of 36 | {ok, VSN} -> 37 | [VSN1 | _] = string:tokens(VSN, "-"), 38 | [Maj|_] = string:tokens(VSN1, "."), 39 | (list_to_integer(Maj) >= 3); 40 | undefined -> 41 | lists:keymember(mix, 1, application:loaded_applications()) 42 | end, 43 | 44 | SysVer = erlang:system_info(otp_release), 45 | 46 | ProcessSingleVar = fun(F, Var, Tail) -> 47 | case F(F, [Var], []) of 48 | [] -> Tail; 49 | [Val] -> [Val | Tail] 50 | end 51 | end, 52 | 53 | ProcessVars = fun(_F, [], Acc) -> 54 | lists:reverse(Acc); 55 | (F, [{Type, Ver, Value} | Tail], Acc) when 56 | Type == if_version_above orelse 57 | Type == if_version_below -> 58 | Include = if Type == if_version_above -> 59 | SysVer > Ver; 60 | true -> 61 | SysVer < Ver 62 | end, 63 | if Include -> 64 | F(F, Tail, ProcessSingleVar(F, Value, Acc)); 65 | true -> 66 | F(F, Tail, Acc) 67 | end; 68 | (F, [{Type, Ver, Value, ElseValue} | Tail], Acc) when 69 | Type == if_version_above orelse 70 | Type == if_version_below -> 71 | Include = if Type == if_version_above -> 72 | SysVer > Ver; 73 | true -> 74 | SysVer < Ver 75 | end, 76 | if Include -> 77 | F(F, Tail, ProcessSingleVar(F, Value, Acc)); 78 | true -> 79 | F(F, Tail, ProcessSingleVar(F, ElseValue, Acc)) 80 | end; 81 | (F, [{Type, Var, Value} | Tail], Acc) when 82 | Type == if_var_true orelse 83 | Type == if_var_false -> 84 | Flag = Type == if_var_true, 85 | case proplists:get_bool(Var, Cfg) of 86 | V when V == Flag -> 87 | F(F, Tail, ProcessSingleVar(F, Value, Acc)); 88 | _ -> 89 | F(F, Tail, Acc) 90 | end; 91 | (F, [{Type, Value} | Tail], Acc) when 92 | Type == if_rebar3 orelse 93 | Type == if_not_rebar3 -> 94 | Flag = Type == if_rebar3, 95 | case IsRebar3 == Flag of 96 | true -> 97 | F(F, Tail, ProcessSingleVar(F, Value, Acc)); 98 | _ -> 99 | F(F, Tail, Acc) 100 | end; 101 | (F, [{Type, Var, Match, Value} | Tail], Acc) when 102 | Type == if_var_match orelse 103 | Type == if_var_no_match -> 104 | case proplists:get_value(Var, Cfg) of 105 | V when V == Match -> 106 | F(F, Tail, ProcessSingleVar(F, Value, Acc)); 107 | _ -> 108 | F(F, Tail, Acc) 109 | end; 110 | (F, [{if_have_fun, MFA, Value} | Tail], Acc) -> 111 | {Mod, Fun, Arity} = MFA, 112 | code:ensure_loaded(Mod), 113 | case erlang:function_exported(Mod, Fun, Arity) of 114 | true -> 115 | F(F, Tail, ProcessSingleVar(F, Value, Acc)); 116 | false -> 117 | F(F, Tail, Acc) 118 | end; 119 | (F, [Other1 | Tail1], Acc) -> 120 | F(F, Tail1, [F(F, Other1, []) | Acc]); 121 | (F, Val, Acc) when is_tuple(Val) -> 122 | list_to_tuple(F(F, tuple_to_list(Val), Acc)); 123 | (_F, Other2, _Acc) -> 124 | Other2 125 | end, 126 | 127 | ModCfg0 = fun(F, Cfg, [Key|Tail], Op, Default) -> 128 | {OldVal,PartCfg} = case lists:keytake(Key, 1, Cfg) of 129 | {value, {_, V1}, V2} -> {V1, V2}; 130 | false -> {if Tail == [] -> Default; true -> [] end, Cfg} 131 | end, 132 | case Tail of 133 | [] -> 134 | [{Key, Op(OldVal)} | PartCfg]; 135 | _ -> 136 | [{Key, F(F, OldVal, Tail, Op, Default)} | PartCfg] 137 | end 138 | end, 139 | ModCfg = fun(Cfg, Keys, Op, Default) -> ModCfg0(ModCfg0, Cfg, Keys, Op, 140 | Default) end, 141 | 142 | ModCfgS = fun(Cfg, Keys, Val) -> ModCfg0(ModCfg0, Cfg, Keys, fun(_V) -> 143 | Val end, "") end, 144 | 145 | 146 | FilterConfig = fun(F, Cfg, [{Path, true, ModFun, Default} | Tail]) -> 147 | F(F, ModCfg0(ModCfg0, Cfg, Path, ModFun, Default), Tail); 148 | (F, Cfg, [_ | Tail]) -> 149 | F(F, Cfg, Tail); 150 | (F, Cfg, []) -> 151 | Cfg 152 | end, 153 | 154 | AppendStr = fun(Append) -> 155 | fun("") -> 156 | Append; 157 | (Val) -> 158 | Val ++ " " ++ Append 159 | end 160 | end, 161 | AppendList = fun(Append) -> 162 | fun(Val) -> 163 | Val ++ Append 164 | end 165 | end, 166 | 167 | % Convert our rich deps syntax to rebar2 format: 168 | % https://github.com/rebar/rebar/wiki/Dependency-management 169 | Rebar2DepsFilter = 170 | fun(DepsList) -> 171 | lists:map(fun({DepName, _HexVersion, Source}) -> 172 | {DepName, ".*", Source} 173 | end, DepsList) 174 | end, 175 | 176 | % Convert our rich deps syntax to rebar3 version definition format: 177 | % https://rebar3.org/docs/configuration/dependencies/#dependency-version-handling 178 | % https://hexdocs.pm/elixir/Version.html 179 | Rebar3DepsFilter = 180 | fun(DepsList) -> 181 | lists:map(fun({DepName, HexVersion, {git, _, {tag, GitVersion}} = Source}) -> 182 | case HexVersion == ".*" of 183 | true -> 184 | {DepName, GitVersion}; 185 | false -> 186 | {DepName, HexVersion} 187 | end; 188 | ({DepName, _HexVersion, Source}) -> 189 | {DepName, ".*", Source} 190 | end, DepsList) 191 | end, 192 | 193 | GlobalDepsFilter = fun(Deps) -> 194 | DepNames = lists:map(fun({DepName, _, _}) -> DepName; 195 | ({DepName, _}) -> DepName 196 | end, Deps), 197 | lists:filtermap(fun(Dep) -> 198 | case code:lib_dir(Dep) of 199 | {error, _} -> 200 | {true,"Unable to locate dep '"++atom_to_list(Dep)++"' in system deps."}; 201 | _ -> 202 | false 203 | end 204 | end, DepNames) 205 | end, 206 | 207 | GithubConfig = case {os:getenv("GITHUB_ACTIONS"), os:getenv("GITHUB_TOKEN")} of 208 | {"true", Token} when is_list(Token) -> 209 | CONFIG1 = [{coveralls_repo_token, Token}, 210 | {coveralls_service_job_id, os:getenv("GITHUB_RUN_ID")}, 211 | {coveralls_commit_sha, os:getenv("GITHUB_SHA")}, 212 | {coveralls_service_number, os:getenv("GITHUB_RUN_NUMBER")}], 213 | case os:getenv("GITHUB_EVENT_NAME") =:= "pull_request" 214 | andalso string:tokens(os:getenv("GITHUB_REF"), "/") of 215 | [_, "pull", PRNO, _] -> 216 | [{coveralls_service_pull_request, PRNO} | CONFIG1]; 217 | _ -> 218 | CONFIG1 219 | end; 220 | _ -> 221 | [] 222 | end, 223 | 224 | Rules = [ 225 | {[deps], (not IsRebar3), 226 | Rebar2DepsFilter, []}, 227 | {[deps], IsRebar3, 228 | Rebar3DepsFilter, []}, 229 | {[plugins], IsRebar3, 230 | AppendList([pc]), []}, 231 | {[plugins], os:getenv("COVERALLS") == "true", 232 | AppendList([{coveralls, {git, 233 | "https://github.com/processone/coveralls-erl.git", 234 | {branch, "addjsonfile"}}} ]), []}, 235 | {[deps], os:getenv("USE_GLOBAL_DEPS") /= false, 236 | GlobalDepsFilter, []} 237 | ], 238 | 239 | 240 | Config = FilterConfig(FilterConfig, ProcessVars(ProcessVars, CONFIG, []), Rules) ++ GithubConfig, 241 | 242 | %io:format("Rules:~n~p~n~nCONFIG:~n~p~n~nConfig:~n~p~n", [Rules, CONFIG, Config]), 243 | 244 | Config. 245 | 246 | %% Local Variables: 247 | %% mode: erlang 248 | %% End: 249 | %% vim: set filetype=erlang tabstop=8: 250 | -------------------------------------------------------------------------------- /src/p1_acme.app.src: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author Evgeny Khramtsov 3 | %%% @copyright (C) 2002-2025 ProcessOne, SARL. All Rights Reserved. 4 | %%% 5 | %%% Licensed under the Apache License, Version 2.0 (the "License"); 6 | %%% you may not use this file except in compliance with the License. 7 | %%% You may obtain a copy of the License at 8 | %%% 9 | %%% http://www.apache.org/licenses/LICENSE-2.0 10 | %%% 11 | %%% Unless required by applicable law or agreed to in writing, software 12 | %%% distributed under the License is distributed on an "AS IS" BASIS, 13 | %%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | %%% See the License for the specific language governing permissions and 15 | %%% limitations under the License. 16 | %%% 17 | %%%------------------------------------------------------------------- 18 | {application, p1_acme, 19 | [{description, "ACME client"}, 20 | {vsn, "1.0.26"}, 21 | {modules, []}, 22 | {registered, []}, 23 | {applications, [kernel, stdlib, 24 | crypto, inets, public_key, ssl, 25 | base64url, idna, jiffy, jose, yconf]}, 26 | {env, []}, 27 | {mod, {p1_acme, []}}, 28 | 29 | %% hex.pm packaging: 30 | {licenses, ["Apache 2.0"]}, 31 | {links, [{"Github", "https://github.com/processone/p1_acme"}]} 32 | ]}. 33 | 34 | %% Local Variables: 35 | %% mode: erlang 36 | %% End: 37 | %% vim: set filetype=erlang tabstop=8: 38 | -------------------------------------------------------------------------------- /src/p1_acme.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author Evgeny Khramtsov 3 | %%% @copyright (C) 2002-2025 ProcessOne, SARL. All Rights Reserved. 4 | %%% 5 | %%% Licensed under the Apache License, Version 2.0 (the "License"); 6 | %%% you may not use this file except in compliance with the License. 7 | %%% You may obtain a copy of the License at 8 | %%% 9 | %%% http://www.apache.org/licenses/LICENSE-2.0 10 | %%% 11 | %%% Unless required by applicable law or agreed to in writing, software 12 | %%% distributed under the License is distributed on an "AS IS" BASIS, 13 | %%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | %%% See the License for the specific language governing permissions and 15 | %%% limitations under the License. 16 | %%% 17 | %%%------------------------------------------------------------------- 18 | -module(p1_acme). 19 | 20 | %% API 21 | -export([start/0, stop/0]). 22 | -export([issue/2, issue/3, issue/4]). 23 | -export([revoke/3, revoke/4]). 24 | -export([generate_key/1]). 25 | -export([generate_csr/2]). 26 | -export([format_error/1]). 27 | 28 | %% OTP Application API 29 | -export([start/2]). 30 | 31 | -include_lib("public_key/include/public_key.hrl"). 32 | 33 | -define(DER_NULL, <<5, 0>>). 34 | -define(DEFAULT_TIMEOUT, timer:minutes(1)). 35 | -define(RETRY_TIMEOUT, 500). 36 | -define(DEBUG(Fmt, Args), 37 | case State#state.debug_fun of 38 | undefined -> ok; 39 | _ -> (State#state.debug_fun)(Fmt, Args) 40 | end). 41 | 42 | -record(state, {command :: command(), 43 | dir_url :: string(), 44 | domains :: [domain()], 45 | contact :: [binary()], 46 | end_time :: integer(), 47 | challenge_type :: undefined | binary(), 48 | account :: undefined | {priv_key(), undefined | string()}, 49 | cert_type :: undefined | cert_type(), 50 | cert :: undefined | cert(), 51 | cert_key :: undefined | priv_key(), 52 | ca_certs :: [cert()], 53 | nonce :: undefined | binary(), 54 | new_acc_url :: undefined | string(), 55 | new_nonce_url :: undefined | string(), 56 | new_order_url :: undefined | string(), 57 | revoke_url :: undefined | string(), 58 | order_url :: undefined | string(), 59 | debug_fun :: undefined | debug_fun(), 60 | challenge_fun :: undefined | challenge_fun()}). 61 | 62 | -type state() :: #state{}. 63 | -type command() :: issue | revoke. 64 | -type priv_key() :: public_key:private_key(). 65 | -type pub_key() :: #'RSAPublicKey'{} | #'ECPoint'{}. 66 | -type cert() :: #'OTPCertificate'{}. 67 | -type domain() :: string(). %% UTF-8 charlist() 68 | -type cert_type() :: ec | rsa. 69 | -type challenge_data() :: [#{domain := domain(), 70 | token := binary(), 71 | key := binary()}]. 72 | -type challenge_fun() :: fun((challenge_data()) -> any()). 73 | -type challenge_type() :: 'http-01'. 74 | -type debug_fun() :: fun((string(), list()) -> _). 75 | -type http_req_fun() :: fun((state()) -> {http_method(), _}). 76 | -type option() :: {timeout, pos_integer()} | 77 | {debug_fun, debug_fun()}. 78 | -type issue_option() :: {contact, [binary() | string()]} | 79 | {cert_type, cert_type()} | 80 | {cert_key, priv_key()} | 81 | {ca_certs, [cert()]} | 82 | {challenge_type, challenge_type()} | 83 | {challenge_fun, challenge_fun()} | 84 | option(). 85 | -type revoke_option() :: option(). 86 | -type http_method() :: get | post | head. 87 | -type http_header() :: {string(), string()}. 88 | -type http_json() :: {100..699, [http_header()], map()}. 89 | -type http_bin() :: {100..699, [http_header()], binary()}. 90 | -type bad_cert_reason() :: cert_expired | invalid_issuer | invalid_signature | 91 | name_not_permitted | missing_basic_constraint | 92 | invalid_key_usage | selfsigned_peer | unknown_ca | 93 | empty_chain | key_mismatch. 94 | -type error_reason() :: {codec_error, yconf:error_reason(), yconf:ctx(), map()} | 95 | {http_error, term()} | 96 | {challenge_failed, domain(), undefined | p1_acme_codec:err_obj()} | 97 | {unsupported_challenges, domain(), [string()]} | 98 | {bad_pem, string()} | 99 | {bad_der, string()} | 100 | {bad_json, binary()} | 101 | {bad_cert, bad_cert_reason()} | 102 | {problem_report, p1_acme_codec:err_obj()}. 103 | -type error_return() :: {error, error_reason()}. 104 | -type issue_return() :: {ok, #{acc_key := priv_key(), 105 | cert_key := priv_key(), 106 | cert_chain := [cert(), ...], 107 | validation_result => 108 | valid | {bad_cert, bad_cert_reason()}}} | 109 | error_return(). 110 | -type revoke_return() :: ok | error_return(). 111 | -type acme_return() :: issue_return() | revoke_return(). 112 | 113 | -export_type([error_reason/0, issue_return/0, revoke_return/0, challenge_data/0]). 114 | 115 | %%%=================================================================== 116 | %%% OTP Application API 117 | %%%=================================================================== 118 | 119 | start(_StartType, _StartArgs) -> 120 | application:start(inets), 121 | inets:start(httpc, [{profile, ?MODULE}]), 122 | httpc:set_options([{ipfamily, inet6fb4}], ?MODULE), 123 | {ok, self()}. 124 | 125 | %%%=================================================================== 126 | %%% API 127 | %%%=================================================================== 128 | 129 | start() -> 130 | start(normal, []), 131 | case application:ensure_all_started(?MODULE) of 132 | {ok, _} -> ok; 133 | Err -> Err 134 | end. 135 | 136 | stop() -> 137 | application:stop(?MODULE). 138 | 139 | -spec issue(binary() | string(), [domain()]) -> issue_return(). 140 | issue(DirURL, Domains) -> 141 | issue(DirURL, Domains, generate_key(ec), []). 142 | 143 | -spec issue(binary() | string(), [domain()], 144 | priv_key() | [issue_option()]) -> issue_return(). 145 | issue(DirURL, Domains, Opts) when is_list(Opts) -> 146 | issue(DirURL, Domains, generate_key(ec), Opts); 147 | issue(DirURL, Domains, AccKey) -> 148 | issue(DirURL, Domains, AccKey, []). 149 | 150 | -spec issue(binary() | string(), [domain()], 151 | priv_key(), [issue_option()]) -> issue_return(). 152 | issue(DirURL, Domains, AccKey, Opts) -> 153 | State = init_state(issue, DirURL, Domains, AccKey, Opts), 154 | request_directory(State). 155 | 156 | -spec revoke(binary() | string(), cert(), priv_key()) -> revoke_return(). 157 | revoke(DirURL, Cert, CertKey) -> 158 | revoke(DirURL, Cert, CertKey, []). 159 | 160 | -spec revoke(binary() | string(), cert(), priv_key(), [revoke_option()]) -> revoke_return(). 161 | revoke(DirURL, Cert, CertKey, Opts) -> 162 | State = init_state(revoke, DirURL, Cert, CertKey, Opts), 163 | request_directory(State). 164 | 165 | -spec format_error(error_reason()) -> string(). 166 | format_error({codec_error, Reason, Ctx, _JSON}) -> 167 | format("Codec error: Failed to validate JSON object: ~s " 168 | "(not an ACMEv2 compatible server?)", 169 | [yconf:format_error(Reason, Ctx)]); 170 | format_error({http_error, Err}) -> 171 | "HTTP error: " ++ 172 | case Err of 173 | {code, Code, ""} -> 174 | "unexpected status code: " ++ integer_to_list(Code); 175 | {code, Code, Slogan} -> 176 | format("~ts (~B)", [Slogan, Code]); 177 | {inet, Reason} -> 178 | "transport failure: " ++ format_inet_error(Reason); 179 | {could_not_parse_as_http, _} -> 180 | "received malformed HTTP packet"; 181 | {missing_header, Header} -> 182 | format("missing '~s' header", [Header]); 183 | {unexpected_content_type, Type} -> 184 | format("unexpected content type: ~ts", [Type]); 185 | _ -> 186 | format("~p", [Err]) 187 | end; 188 | format_error({challenge_failed, Domain, undefined}) -> 189 | format("Challenge failed for domain ~ts", [Domain]); 190 | format_error({challenge_failed, Domain, ErrObj}) -> 191 | format("Challenge failed for domain ~ts: ~ts", 192 | [Domain, format_problem_report(ErrObj)]); 193 | format_error({unsupported_challenges, Domain, Types}) -> 194 | format("ACME server offered unsupported challenges for domain ~ts: ~s", 195 | [Domain, string:join(Types, ", ")]); 196 | format_error({bad_pem, URL}) -> 197 | format("Failed to decode PEM certificate chain obtained from ~s", [URL]); 198 | format_error({bad_der, URL}) -> 199 | format("Failed to decode ASN.1 DER certificate in the chain obtained from ~s", [URL]); 200 | format_error({bad_json, Data}) -> 201 | format("Failed to decode JSON: ~s", [Data]); 202 | format_error({bad_cert, Reason}) -> 203 | format_bad_cert_error(Reason); 204 | format_error({problem_report, ErrObj}) -> 205 | format_problem_report(ErrObj); 206 | format_error(Other) -> 207 | format("Unrecognized error: ~p", [Other]). 208 | 209 | -spec format_bad_cert_error(bad_cert_reason()) -> string(). 210 | format_bad_cert_error(empty_chain) -> 211 | "certificate chain is empty"; 212 | format_bad_cert_error(key_mismatch) -> 213 | "certificate's public key doesn't match local private key"; 214 | format_bad_cert_error(cert_expired) -> 215 | "certificate in the chain is no longer valid as its expiration date has passed"; 216 | format_bad_cert_error(invalid_issuer) -> 217 | "certificate issuer name does not match the name of the " 218 | "issuer certificate"; 219 | format_bad_cert_error(invalid_signature) -> 220 | "certificate in the chain was not signed by its issuer certificate"; 221 | format_bad_cert_error(name_not_permitted) -> 222 | "invalid Subject Alternative Name extension"; 223 | format_bad_cert_error(missing_basic_constraint) -> 224 | "certificate, required to have the basic constraints extension, " 225 | "does not have a basic constraints extension"; 226 | format_bad_cert_error(invalid_key_usage) -> 227 | "certificate key is used in an invalid way according " 228 | "to the key-usage extension"; 229 | format_bad_cert_error(selfsigned_peer) -> 230 | "self-signed certificate in the chain"; 231 | format_bad_cert_error(unknown_ca) -> 232 | "certificate chain is signed by unknown CA". 233 | 234 | -spec format_inet_error(atom()) -> string(). 235 | format_inet_error(Reason) when is_atom(Reason) -> 236 | case inet:format_error(Reason) of 237 | "unknown POSIX error" -> atom_to_list(Reason); 238 | Txt -> Txt 239 | end. 240 | 241 | -spec format_problem_report(p1_acme_codec:err_obj()) -> string(). 242 | format_problem_report(#{type := Type, detail := Detail}) -> 243 | format("ACME server reported: ~ts (error type: ~s)", [Detail, Type]); 244 | format_problem_report(#{type := Type}) -> 245 | format("ACME server responded with ~s error", [Type]). 246 | 247 | -spec format(string(), list()) -> list(). 248 | format(Fmt, Args) -> 249 | lists:flatten(io_lib:format(Fmt, Args)). 250 | 251 | %%%=================================================================== 252 | %%% Internal functions 253 | %%%=================================================================== 254 | %%%=================================================================== 255 | %%% ACME Requests 256 | %%%=================================================================== 257 | -spec request_directory(state()) -> acme_return(). 258 | request_directory(State) -> 259 | Req = fun(S) -> 260 | {get, {S#state.dir_url, []}} 261 | end, 262 | case http_request(State, Req) of 263 | {ok, Reply, State1} -> 264 | handle_directory_response(Reply, State1); 265 | Err -> 266 | Err 267 | end. 268 | 269 | -spec request_new_nonce(state()) -> acme_return(). 270 | request_new_nonce(State) -> 271 | Req = fun(S) -> 272 | {head, {S#state.new_nonce_url, []}} 273 | end, 274 | case http_request(State, Req) of 275 | {ok, Reply, State1} -> 276 | handle_nonce_response(Reply, State1); 277 | Err -> 278 | Err 279 | end. 280 | 281 | -spec request_new_account(state()) -> issue_return(). 282 | request_new_account(State) -> 283 | Req = fun(S) -> 284 | Body = #{<<"termsOfServiceAgreed">> => true, 285 | <<"contact">> => S#state.contact}, 286 | JoseJSON = jose_json(S, Body, S#state.new_acc_url), 287 | {post, {S#state.new_acc_url, [], 288 | "application/jose+json", JoseJSON}} 289 | end, 290 | case http_request(State, Req) of 291 | {ok, Reply, State1} -> 292 | handle_account_response(Reply, State1); 293 | Err -> 294 | Err 295 | end. 296 | 297 | -spec request_new_order(state()) -> issue_return(). 298 | request_new_order(State) -> 299 | Req = fun(S) -> 300 | Body = #{<<"identifiers">> => 301 | [#{<<"type">> => <<"dns">>, 302 | <<"value">> => 303 | list_to_binary(idna:to_ascii(Domain))} 304 | || Domain <- S#state.domains]}, 305 | JoseJSON = jose_json(S, Body, S#state.new_order_url), 306 | {post, {S#state.new_order_url, [], 307 | "application/jose+json", JoseJSON}} 308 | end, 309 | case http_request(State, Req) of 310 | {ok, Reply, State1} -> 311 | handle_order_response(Reply, State1); 312 | Err -> 313 | Err 314 | end. 315 | 316 | -spec request_domain_auth(state(), [string()]) -> 317 | {ok, state(), [{domain(), p1_acme_codec:challenge_obj()}]} | 318 | error_return(). 319 | request_domain_auth(State, AuthURLs) -> 320 | request_domain_auth(State, AuthURLs, []). 321 | 322 | -spec request_domain_auth(state(), [string()], 323 | [{domain(), p1_acme_codec:challenge_obj()}]) -> 324 | {ok, state(), [{domain(), p1_acme_codec:challenge_obj()}]} | 325 | error_return(). 326 | request_domain_auth(State, [URL|URLs], Challenges) -> 327 | Req = fun(S) -> 328 | JoseJSON = jose_json(S, <<>>, URL), 329 | {post, {URL, [], "application/jose+json", JoseJSON}} 330 | end, 331 | case http_request(State, Req) of 332 | {ok, Reply, State1} -> 333 | case handle_domain_auth_response(Reply, State1) of 334 | {ok, Challenge} -> 335 | request_domain_auth(State1, URLs, [Challenge|Challenges]); 336 | Err -> 337 | Err 338 | end; 339 | Err -> 340 | Err 341 | end; 342 | request_domain_auth(State, [], Challenges) -> 343 | {ok, State, Challenges}. 344 | 345 | -spec request_challenges(state(), [{domain(), p1_acme_codec:challenge_obj()}]) -> issue_return(). 346 | request_challenges(State, Challenges) -> 347 | {Pending, _InProgress, _Valid, Invalid} = split_challenges(Challenges), 348 | case Invalid of 349 | [{Domain, Challenge}|_] -> 350 | Reason = maps:get(error, Challenge, undefined), 351 | mk_error({challenge_failed, Domain, Reason}); 352 | [] -> 353 | case Pending of 354 | [_|_] -> 355 | Args = lists:map( 356 | fun({Domain, #{token := Token}}) -> 357 | #{domain => Domain, 358 | key => auth_key(State, Token), 359 | token => Token} 360 | end, Pending), 361 | (State#state.challenge_fun)(Args); 362 | [] -> 363 | ok 364 | end, 365 | case lists:foldl( 366 | fun(_, {error, _} = Err) -> 367 | Err; 368 | ({_, C}, S) -> 369 | request_challenge(S, C) 370 | end, State, Pending) of 371 | {error, Reason} -> {error, Reason}; 372 | State1 -> poll(State1) 373 | end 374 | end. 375 | 376 | -spec request_challenge(state(), p1_acme_codec:challenge_obj()) -> state() | error_return(). 377 | request_challenge(State, #{url := URL0}) -> 378 | URL = binary_to_list(URL0), 379 | Req = fun(S) -> 380 | JoseJSON = jose_json(S, #{}, URL), 381 | {post, {URL, [], "application/jose+json", JoseJSON}} 382 | end, 383 | case http_request(State, Req) of 384 | {ok, _Reply, State1} -> 385 | State1; 386 | Err -> 387 | Err 388 | end. 389 | 390 | -spec request_certificate(state(), string()) -> issue_return(). 391 | request_certificate(State, URL) -> 392 | {DerCSR, State1} = generate_csr(State), 393 | Body = #{<<"csr">> => base64url:encode(DerCSR)}, 394 | Req = fun(S) -> 395 | JoseJSON = jose_json(S, Body, URL), 396 | {post, {URL, [], "application/jose+json", JoseJSON}} 397 | end, 398 | case http_request(State1, Req) of 399 | {ok, Reply, State2} -> 400 | handle_order_response(Reply, State2); 401 | Err -> 402 | Err 403 | end. 404 | 405 | -spec revoke_certificate(state()) -> revoke_return(). 406 | revoke_certificate(#state{revoke_url = URL, 407 | cert_key = CertKey, 408 | cert = Cert} = State) -> 409 | DerCert = public_key:pkix_encode('OTPCertificate', Cert, otp), 410 | Body = #{<<"certificate">> => base64url:encode(DerCert)}, 411 | State1 = State#state{account = {CertKey, undefined}}, 412 | Req = fun(S) -> 413 | JoseJSON = jose_json(S, Body, URL), 414 | {post, {URL, [], "application/jose+json", JoseJSON}} 415 | end, 416 | case http_request(State1, Req) of 417 | {ok, _, _} -> 418 | ok; 419 | Err -> 420 | Err 421 | end. 422 | 423 | -spec request_pem_file(state(), string()) -> issue_return(). 424 | request_pem_file(State, URL) -> 425 | Req = fun(S) -> 426 | JoseJSON = jose_json(S, <<>>, URL), 427 | {post, {URL, [], "application/jose+json", JoseJSON}} 428 | end, 429 | case http_request(State, Req) of 430 | {ok, Reply, State1} -> 431 | handle_pem_file_response(Reply, URL, State1); 432 | Err -> 433 | Err 434 | end. 435 | 436 | -spec poll(state()) -> issue_return(). 437 | poll(State) -> 438 | poll(State, ?RETRY_TIMEOUT). 439 | 440 | -spec poll(state(), non_neg_integer()) -> issue_return(). 441 | poll(#state{order_url = URL} = State, Timeout) -> 442 | Req = fun(S) -> 443 | JoseJSON = jose_json(S, <<>>, URL), 444 | {post, {URL, [], "application/jose+json", JoseJSON}} 445 | end, 446 | case http_request(State, Req) of 447 | {ok, Reply, State1} -> 448 | handle_poll_response(Reply, State1, Timeout); 449 | Err -> 450 | Err 451 | end. 452 | 453 | %%%=================================================================== 454 | %%% Response processing 455 | %%%=================================================================== 456 | -spec handle_directory_response(http_json(), state()) -> acme_return(). 457 | handle_directory_response({_, _Hdrs, JSON}, State) -> 458 | case p1_acme_codec:decode_dir_obj(JSON) of 459 | {ok, #{newNonce := NonceURL, 460 | newAccount := AccURL, 461 | newOrder := OrderURL, 462 | revokeCert := RevokeURL}} -> 463 | State1 = State#state{new_nonce_url = binary_to_list(NonceURL), 464 | new_acc_url = binary_to_list(AccURL), 465 | new_order_url = binary_to_list(OrderURL), 466 | revoke_url = binary_to_list(RevokeURL)}, 467 | request_new_nonce(State1); 468 | Err -> 469 | mk_codec_error(Err, JSON) 470 | end. 471 | 472 | -spec handle_nonce_response(http_json(), state()) -> acme_return(). 473 | handle_nonce_response({_, Hdrs, _}, State) -> 474 | case lists:keyfind("replay-nonce", 1, Hdrs) of 475 | {_, Nonce} -> 476 | State1 = State#state{nonce = iolist_to_binary(Nonce)}, 477 | case State1#state.command of 478 | issue -> request_new_account(State1); 479 | revoke -> revoke_certificate(State1) 480 | end; 481 | false -> 482 | mk_http_error({missing_header, 'Replay-Nonce'}) 483 | end. 484 | 485 | -spec handle_account_response(http_json(), state()) -> issue_return(). 486 | handle_account_response({_, Hdrs, JSON}, State) -> 487 | case find_location(Hdrs) of 488 | undefined -> 489 | mk_http_error({missing_header, 'Location'}); 490 | AccURL -> 491 | case p1_acme_codec:decode_acc_obj(JSON) of 492 | {ok, _} -> 493 | {AccKey, _} = State#state.account, 494 | State1 = State#state{account = {AccKey, AccURL}}, 495 | request_new_order(State1); 496 | Err -> 497 | mk_codec_error(Err, JSON) 498 | end 499 | end. 500 | 501 | -spec handle_order_response(http_json(), state()) -> issue_return(). 502 | handle_order_response({_, Hdrs, JSON}, State) -> 503 | case find_location(Hdrs, State#state.order_url) of 504 | undefined -> 505 | mk_http_error({missing_header, 'Location'}); 506 | OrderURL -> 507 | case p1_acme_codec:decode_order_obj(JSON) of 508 | {ok, #{status := ready, 509 | finalize := FinURL}} -> 510 | request_certificate(State, binary_to_list(FinURL)); 511 | {ok, #{status := valid, 512 | certificate := CertURL}} -> 513 | request_pem_file(State, binary_to_list(CertURL)); 514 | {ok, #{authorizations := AuthURLs}} -> 515 | State1 = State#state{order_url = OrderURL}, 516 | case request_domain_auth( 517 | State1, lists:map(fun binary_to_list/1, AuthURLs)) of 518 | {ok, State2, Challenges} -> 519 | request_challenges(State2, Challenges); 520 | Err -> 521 | Err 522 | end; 523 | Err -> 524 | mk_codec_error(Err, JSON) 525 | end 526 | end. 527 | 528 | -spec handle_domain_auth_response(http_json(), state()) -> 529 | {ok, {domain(), p1_acme_codec:challenge_obj()}} | 530 | error_return(). 531 | handle_domain_auth_response({_, _Hdrs, JSON}, State) -> 532 | case p1_acme_codec:decode_auth_obj(JSON) of 533 | {ok, #{challenges := Challenges, 534 | identifier := #{value := D}}} -> 535 | Domain = idna:to_unicode(binary_to_list(D)), 536 | case lists:dropwhile( 537 | fun(#{type := T}) -> 538 | T /= State#state.challenge_type 539 | end, Challenges) of 540 | [Challenge|_] -> {ok, {Domain, Challenge}}; 541 | [] -> 542 | Types = [binary_to_list(maps:get(type, C)) 543 | || C <- Challenges], 544 | mk_error({unsupported_challenges, Domain, Types}) 545 | end; 546 | Err -> 547 | mk_codec_error(Err, JSON) 548 | end. 549 | 550 | -spec handle_poll_response(http_json(), state(), non_neg_integer()) -> issue_return(). 551 | handle_poll_response({_, _, JSON} = Response, State, Timeout) -> 552 | case p1_acme_codec:decode_order_obj(JSON) of 553 | {ok, #{status := Status}} when Status == pending; 554 | Status == processing -> 555 | Timeout1 = min(Timeout, get_timeout(State)), 556 | timer:sleep(Timeout1), 557 | poll(State, Timeout1*2); 558 | {ok, _} -> 559 | handle_order_response(Response, State); 560 | Err -> 561 | mk_codec_error(Err, JSON) 562 | end. 563 | 564 | -spec handle_pem_file_response(http_bin(), string(), state()) -> issue_return(). 565 | handle_pem_file_response({_, _, CertPEM}, URL, 566 | #state{cert_key = CertKey, 567 | ca_certs = CaCerts, 568 | account = {AccKey, _}}) -> 569 | try lists:map( 570 | fun({'Certificate', DER, not_encrypted}) -> DER end, 571 | public_key:pem_decode(CertPEM)) of 572 | DERs -> 573 | try lists:map( 574 | fun(DER) -> 575 | public_key:pkix_decode_cert(DER, otp) 576 | end, DERs) of 577 | [] -> 578 | mk_error({bad_cert, empty_chain}); 579 | CertChain -> 580 | {SortedCertChain, SortedDERs} = sort_cert_chain(CertChain, DERs), 581 | Ret = #{acc_key => AccKey, 582 | cert_key => CertKey, 583 | cert_chain => SortedCertChain}, 584 | Ret1 = case CaCerts of 585 | [] -> Ret; 586 | _ -> 587 | Ret#{validation_result => 588 | validate_cert_chain( 589 | SortedCertChain, SortedDERs, CertKey, CaCerts)} 590 | end, 591 | {ok, Ret1} 592 | catch _:_ -> 593 | mk_error({bad_der, URL}) 594 | end 595 | catch _:_ -> 596 | mk_error({bad_pem, URL}) 597 | end. 598 | 599 | %%%=================================================================== 600 | %%% HTTP request 601 | %%%=================================================================== 602 | -spec http_request(state(), http_req_fun()) -> 603 | {ok, http_json() | http_bin(), state()} | error_return(). 604 | http_request(State, ReqFun) -> 605 | http_request(State, ReqFun, ?RETRY_TIMEOUT). 606 | 607 | -spec http_request(state(), http_req_fun(), non_neg_integer()) -> 608 | {ok, http_json() | http_bin(), state()} | error_return(). 609 | http_request(State, ReqFun, RetryTimeout) -> 610 | case get_timeout(State) of 611 | 0 -> 612 | mk_http_error(etimedout); 613 | Timeout -> 614 | {Method, URL} = Request = ReqFun(State), 615 | ?DEBUG("HTTP request: ~p", [Request]), 616 | case httpc:request(Method, URL, 617 | [{timeout, infinity}, 618 | {connect_timeout, infinity}], 619 | [{body_format, binary}, 620 | {sync, false}], ?MODULE) of 621 | {ok, Ref} -> 622 | ReqTimeout = min(timer:seconds(10), Timeout), 623 | receive 624 | {http, {Ref, Response}} -> 625 | ?DEBUG("HTTP response: ~p", [Response]), 626 | handle_http_response( 627 | ReqFun, Response, State, RetryTimeout) 628 | after ReqTimeout -> 629 | ?DEBUG("HTTP request timeout", []), 630 | httpc:cancel_request(Ref, ?MODULE), 631 | http_request(State, ReqFun, RetryTimeout) 632 | end; 633 | {error, WTF} -> 634 | mk_http_error(WTF) 635 | end 636 | end. 637 | 638 | -spec http_retry(state(), http_req_fun(), non_neg_integer(), error_reason()) -> 639 | {ok, http_json() | http_bin(), state()} | error_return(). 640 | http_retry(State, ReqFun, RetryTimeout, Reason) -> 641 | case {need_retry(Reason), get_timeout(State)} of 642 | {true, Timeout} when Timeout > RetryTimeout -> 643 | timer:sleep(RetryTimeout), 644 | http_request(State, ReqFun, RetryTimeout*2); 645 | _ -> 646 | mk_error(Reason) 647 | end. 648 | 649 | -spec need_retry(error_reason()) -> boolean(). 650 | need_retry({http_error, {inet, Reason}}) -> 651 | case Reason of 652 | ehostdown -> true; 653 | ehostunreach -> true; 654 | enetdown -> true; 655 | enetreset -> true; 656 | enetunreach -> true; 657 | etimedout -> true; 658 | erefused -> true; 659 | econnrefused -> true; 660 | econnreset -> true; 661 | _ -> false 662 | end; 663 | need_retry({http_error, {code, Code, _}}) when Code >= 500, Code < 600 -> true; 664 | need_retry({problem_report, #{type := Type}}) 665 | when Type == badNonce; Type == serverInternal -> 666 | true; 667 | need_retry({problem_report, #{status := Code}}) 668 | when Code >= 500, Code < 600 -> 669 | true; 670 | need_retry(_) -> false. 671 | 672 | %%%=================================================================== 673 | %%% HTTP response processing 674 | %%%=================================================================== 675 | -spec handle_http_response(http_req_fun(), 676 | {{_, 100..699, string()}, [http_header()], binary()} | term(), 677 | state(), non_neg_integer()) -> 678 | {ok, http_json() | http_bin(), state()} | error_return(). 679 | handle_http_response(ReqFun, {{_, Code, Slogan}, Hdrs, Body}, State, RetryTimeout) -> 680 | case lists:keyfind("content-type", 1, Hdrs) of 681 | {_, Type} when Type == "application/problem+json"; 682 | Type == "application/json" -> 683 | State1 = update_nonce(Hdrs, State), 684 | try json_decode_maps(Body) of 685 | JSON when Type == "application/json" -> 686 | {ok, {Code, Hdrs, JSON}, State1}; 687 | JSON when Type == "application/problem+json" -> 688 | case p1_acme_codec:decode_err_obj(JSON) of 689 | {ok, ErrObj} -> 690 | http_retry(State1, ReqFun, RetryTimeout, 691 | {problem_report, ErrObj}); 692 | Err -> 693 | mk_codec_error(Err, JSON) 694 | end 695 | catch _:_ -> 696 | mk_error({bad_json, Body}) 697 | end; 698 | {_, Type} when Code >= 200, Code < 300 -> 699 | case Type of 700 | "application/pem-certificate-chain" -> 701 | {ok, {Code, Hdrs, Body}, State}; 702 | _ -> 703 | mk_http_error({unexpected_content_type, Type}) 704 | end; 705 | false when Code >= 200, Code < 300 -> 706 | case Body of 707 | <<>> -> 708 | {ok, {Code, Hdrs, #{}}, State}; 709 | _ -> 710 | mk_http_error({missing_header, 'Content-Type'}) 711 | end; 712 | _ when Code >= 500, Code < 600 -> 713 | http_retry(State, ReqFun, RetryTimeout, 714 | prep_http_error({code, Code, Slogan})); 715 | _ -> 716 | mk_http_error({code, Code, Slogan}) 717 | end; 718 | handle_http_response(ReqFun, {error, Reason}, State, RetryTimeout) -> 719 | http_retry(State, ReqFun, RetryTimeout, prep_http_error(Reason)); 720 | handle_http_response(ReqFun, Term, State, RetryTimeout) -> 721 | http_retry(State, ReqFun, RetryTimeout, prep_http_error(Term)). 722 | 723 | prep_http_error({failed_connect, List} = Reason) when is_list(List) -> 724 | {http_error, 725 | case lists:keyfind(inet, 1, List) of 726 | {_, _, Why} when is_atom(Why) -> 727 | {inet, 728 | case Why of 729 | timeout -> etimedout; 730 | closed -> econnreset; 731 | _ -> Why 732 | end}; 733 | _ -> 734 | Reason 735 | end}; 736 | prep_http_error(socket_closed_remotely) -> 737 | {http_error, {inet, econnreset}}; 738 | prep_http_error(Reason) -> 739 | {http_error, Reason}. 740 | 741 | -spec update_nonce([http_header()], state()) -> state(). 742 | update_nonce(Hdrs, State) -> 743 | case lists:keyfind("replay-nonce", 1, Hdrs) of 744 | {_, Nonce} -> 745 | State#state{nonce = iolist_to_binary(Nonce)}; 746 | false -> 747 | State 748 | end. 749 | 750 | %%%=================================================================== 751 | %%% Crypto stuff 752 | %%%=================================================================== 753 | -spec generate_key(cert_type()) -> priv_key(). 754 | generate_key(ec) -> 755 | public_key:generate_key({namedCurve, secp256r1}); 756 | generate_key(rsa) -> 757 | public_key:generate_key({rsa, 2048, 65537}). 758 | 759 | %% OTP-28.0-rc4 in commit b230e26c4f6530563919b19e76f2d2e96e436048 760 | %% removed in the file lib/public_key/asn1/PKCS-10.asn1 761 | %% several definitions. Let's add manually the macro here: 762 | -define('p1_acme-pkcs-9-at-extensionRequest', {1,2,840,113549,1,9,14}). 763 | 764 | -spec generate_csr([domain(), ...], priv_key()) -> #'CertificationRequest'{}. 765 | generate_csr([_|_] = Domains, PrivKey) -> 766 | SignAlgoOID = signature_algorithm(PrivKey), 767 | PubKey = pubkey_from_privkey(PrivKey), 768 | {DigestType, _} = public_key:pkix_sign_types(SignAlgoOID), 769 | DerParams = der_params(PrivKey), 770 | DerSAN = public_key:der_encode( 771 | 'SubjectAltName', 772 | [{dNSName, idna:to_ascii(Domain)} || Domain <- Domains]), 773 | Extns = [#'Extension'{extnID = ?'id-ce-subjectAltName', 774 | critical = false, 775 | extnValue = DerSAN}], 776 | DerExtnReq = public_key:der_encode('ExtensionRequest', Extns), 777 | Attribute = #'AttributePKCS-10'{type = ?'p1_acme-pkcs-9-at-extensionRequest', 778 | values = [{asn1_OPENTYPE, DerExtnReq}]}, 779 | SubjPKInfo = #'CertificationRequestInfo_subjectPKInfo'{ 780 | subjectPublicKey = subject_pubkey(PubKey), 781 | algorithm = 782 | #'CertificationRequestInfo_subjectPKInfo_algorithm'{ 783 | algorithm = algorithm(PrivKey), 784 | parameters = {asn1_OPENTYPE, DerParams}}}, 785 | CsrInfo = #'CertificationRequestInfo'{ 786 | version = v1, 787 | subject = {rdnSequence, []}, 788 | subjectPKInfo = SubjPKInfo, 789 | attributes = [Attribute]}, 790 | DerCsrInfo = public_key:der_encode('CertificationRequestInfo', CsrInfo), 791 | Signature = public_key:sign(DerCsrInfo, DigestType, PrivKey), 792 | #'CertificationRequest'{ 793 | certificationRequestInfo = CsrInfo, 794 | signatureAlgorithm = 795 | #'CertificationRequest_signatureAlgorithm'{ 796 | algorithm = SignAlgoOID}, 797 | signature = Signature}. 798 | 799 | -spec generate_csr(state()) -> {binary(), state()}. 800 | generate_csr(#state{domains = Domains, 801 | cert_type = Type, 802 | cert_key = Key} = State) -> 803 | CertKey = case Key of 804 | undefined -> generate_key(Type); 805 | _ -> Key 806 | end, 807 | CSR = generate_csr(Domains, CertKey), 808 | ?DEBUG("CSR = ~p", [CSR]), 809 | {public_key:der_encode('CertificationRequest', CSR), 810 | State#state{cert_type = cert_type(CertKey), cert_key = CertKey}}. 811 | 812 | -spec cert_type(priv_key()) -> cert_type(). 813 | cert_type(#'RSAPrivateKey'{}) -> rsa; 814 | cert_type(#'ECPrivateKey'{}) -> ec. 815 | 816 | signature_algorithm(#'ECPrivateKey'{}) -> 817 | ?'ecdsa-with-SHA256'; 818 | signature_algorithm(#'RSAPrivateKey'{}) -> 819 | ?'sha256WithRSAEncryption'. 820 | 821 | algorithm(#'ECPrivateKey'{}) -> 822 | ?'id-ecPublicKey'; 823 | algorithm(#'RSAPrivateKey'{}) -> 824 | ?'rsaEncryption'. 825 | 826 | -spec pubkey_from_privkey(priv_key()) -> pub_key(). 827 | pubkey_from_privkey(#'RSAPrivateKey'{modulus = Modulus, 828 | publicExponent = Exp}) -> 829 | #'RSAPublicKey'{modulus = Modulus, 830 | publicExponent = Exp}; 831 | pubkey_from_privkey(#'ECPrivateKey'{publicKey = Key}) -> 832 | #'ECPoint'{point = Key}. 833 | 834 | -spec subject_pubkey(pub_key()) -> binary(). 835 | subject_pubkey(#'ECPoint'{point = Point}) -> 836 | Point; 837 | subject_pubkey(#'RSAPublicKey'{} = Key) -> 838 | public_key:der_encode('RSAPublicKey', Key). 839 | 840 | -spec der_params(priv_key()) -> binary(). 841 | der_params(#'ECPrivateKey'{parameters = Params}) -> 842 | public_key:der_encode('EcpkParameters', Params); 843 | der_params(_) -> 844 | ?DER_NULL. 845 | 846 | -spec pubkey_from_cert(cert()) -> pub_key(). 847 | pubkey_from_cert(Cert) -> 848 | TBSCert = Cert#'OTPCertificate'.tbsCertificate, 849 | PubKeyInfo = TBSCert#'OTPTBSCertificate'.subjectPublicKeyInfo, 850 | SubjPubKey = PubKeyInfo#'OTPSubjectPublicKeyInfo'.subjectPublicKey, 851 | case PubKeyInfo#'OTPSubjectPublicKeyInfo'.algorithm of 852 | #'PublicKeyAlgorithm'{ 853 | algorithm = ?'rsaEncryption'} -> 854 | SubjPubKey; 855 | #'PublicKeyAlgorithm'{ 856 | algorithm = ?'id-ecPublicKey'} -> 857 | SubjPubKey 858 | end. 859 | 860 | -spec validate_cert_chain([cert(), ...], [binary(), ...], priv_key(), [cert()]) -> 861 | valid | {bad_cert, bad_cert_reason()}. 862 | validate_cert_chain([Cert|_] = Certs, DerCerts, PrivKey, CaCerts) -> 863 | case pubkey_from_privkey(PrivKey) == pubkey_from_cert(Cert) of 864 | false -> {bad_cert, key_mismatch}; 865 | true -> 866 | Last = lists:last(Certs), 867 | case find_issuer_cert(Last, CaCerts) of 868 | {ok, CaCert} -> 869 | case public_key:pkix_path_validation( 870 | CaCert, lists:reverse(DerCerts), []) of 871 | {ok, _} -> valid; 872 | {error, {bad_cert, _} = Reason} -> Reason 873 | end; 874 | error -> 875 | case public_key:pkix_is_self_signed(Last) of 876 | true -> 877 | {bad_cert, selfsigned_peer}; 878 | false -> 879 | {bad_cert, unknown_ca} 880 | end 881 | end 882 | end. 883 | 884 | -spec sort_cert_chain([cert()], [binary()]) -> {[cert()], [binary()]}. 885 | sort_cert_chain(Certs, DERs) -> 886 | lists:unzip( 887 | lists:sort( 888 | fun({Cert1, _}, {Cert2, _}) -> 889 | public_key:pkix_is_issuer(Cert1, Cert2) 890 | end, lists:zip(Certs, DERs))). 891 | 892 | -spec find_issuer_cert(cert(), [cert()]) -> {ok, cert()} | error. 893 | find_issuer_cert(Cert, [IssuerCert|IssuerCerts]) -> 894 | case public_key:pkix_is_issuer(Cert, IssuerCert) of 895 | true -> {ok, IssuerCert}; 896 | false -> find_issuer_cert(Cert, IssuerCerts) 897 | end; 898 | find_issuer_cert(_Cert, []) -> 899 | error. 900 | 901 | %%%=================================================================== 902 | %%% JOSE idiotism 903 | %%%=================================================================== 904 | -spec jose_json(state(), binary() | map(), binary() | string()) -> binary(). 905 | jose_json(State, JSON, URL) when is_map(JSON) -> 906 | jose_json(State, encode_json(JSON), URL); 907 | jose_json(#state{account = {Key, AccURL}, nonce = Nonce} = State, Data, URL) -> 908 | PrivKey = jose_jwk:from_key(Key), 909 | PubKey = jose_jwk:to_public(PrivKey), 910 | AlgMap = case jose_jwk:signer(PrivKey) of 911 | M when is_record(Key, 'RSAPrivateKey') -> 912 | M#{<<"alg">> => <<"RS256">>}; 913 | M -> 914 | M 915 | end, 916 | JwsMap0 = #{<<"nonce">> => Nonce, 917 | <<"url">> => iolist_to_binary(URL)}, 918 | JwsMap = case AccURL of 919 | undefined -> 920 | {_, BinaryPubKey} = jose_jwk:to_binary(PubKey), 921 | PubKeyJson = json_decode_maps(BinaryPubKey), 922 | JwsMap0#{<<"jwk">> => PubKeyJson}; 923 | _ -> 924 | JwsMap0#{<<"kid">> => iolist_to_binary(AccURL)} 925 | end, 926 | JwsObj = jose_jws:from(maps:merge(JwsMap, AlgMap)), 927 | ?DEBUG("JOSE payload: ~s~nJOSE protected: ~p", [Data, JwsObj]), 928 | {_, JoseJSON} = jose_jws:sign(PrivKey, Data, JwsObj), 929 | encode_json(JoseJSON). 930 | 931 | -spec auth_key(state(), binary()) -> binary(). 932 | auth_key(#state{account = {PrivKey, _}}, Token) -> 933 | Thumbprint = jose_jwk:thumbprint(jose_jwk:from_key(PrivKey)), 934 | <>. 935 | 936 | -spec encode_json(map()) -> binary(). 937 | encode_json(JSON) -> 938 | json_encode(JSON). 939 | 940 | -ifdef(OTP_BELOW_27). 941 | json_encode(Term) -> 942 | iolist_to_binary(jiffy:encode(Term)). 943 | json_decode_maps(Bin) -> 944 | jiffy:decode(Bin, [return_maps]). 945 | -else. 946 | json_encode(Term) -> 947 | iolist_to_binary(json:encode(Term)). 948 | json_decode_maps(Bin) -> 949 | json:decode(Bin). 950 | -endif. 951 | 952 | %%%=================================================================== 953 | %%% Misc 954 | %%%=================================================================== 955 | -spec mk_http_error(term()) -> error_return(). 956 | mk_http_error(Reason) -> 957 | mk_error({http_error, Reason}). 958 | 959 | -spec mk_codec_error(yconf:error_return(), map()) -> error_return(). 960 | mk_codec_error({error, Reason, Ctx}, JSON) -> 961 | mk_error({codec_error, Reason, Ctx, JSON}). 962 | 963 | -spec mk_error(error_reason()) -> error_return(). 964 | mk_error(Reason) -> 965 | {error, Reason}. 966 | 967 | -spec current_time() -> integer(). 968 | current_time() -> 969 | erlang:monotonic_time(millisecond). 970 | 971 | -spec get_timeout(state()) -> non_neg_integer(). 972 | get_timeout(#state{end_time = EndTime}) -> 973 | max(0, EndTime - current_time()). 974 | 975 | -spec check_url(binary() | string()) -> string(). 976 | check_url(S) -> 977 | case yconf:validate(yconf:url(), iolist_to_binary(S)) of 978 | {ok, URL} -> binary_to_list(URL); 979 | _ -> erlang:error(badarg, [S]) 980 | end. 981 | 982 | -spec init_state(issue, binary() | string(), [domain()], priv_key(), 983 | [issue_option()]) -> state(); 984 | (revoke, binary() | string(), cert(), priv_key(), 985 | [revoke_option()]) -> state(). 986 | init_state(issue, DirURL, Domains, AccKey, Opts) -> 987 | State = #state{command = issue, 988 | dir_url = check_url(DirURL), 989 | domains = Domains, 990 | account = {AccKey, undefined}, 991 | contact = [], 992 | cert_type = ec, 993 | ca_certs = [], 994 | challenge_type = <<"http-01">>, 995 | end_time = current_time() + ?DEFAULT_TIMEOUT}, 996 | lists:foldl( 997 | fun({timeout, Timeout}, S) when is_integer(Timeout), Timeout > 0 -> 998 | EndTime = current_time() + Timeout, 999 | S#state{end_time = EndTime}; 1000 | ({contact, Cs}, S) when is_list(Cs) -> 1001 | S#state{contact = lists:map(fun iolist_to_binary/1, Cs)}; 1002 | ({cert_type, T}, S) when T == ec; T == rsa -> 1003 | S#state{cert_type = T}; 1004 | ({cert_key, K}, S) -> 1005 | S#state{cert_key = K}; 1006 | ({ca_certs, L}, S) when is_list(L) -> 1007 | S#state{ca_certs = L}; 1008 | ({challenge_type, 'http-01'}, S) -> 1009 | S#state{challenge_type = <<"http-01">>}; 1010 | ({challenge_fun, Fun}, S) when is_function(Fun, 1) -> 1011 | S#state{challenge_fun = Fun}; 1012 | ({debug_fun, Fun}, S) when is_function(Fun, 2) -> 1013 | S#state{debug_fun = Fun}; 1014 | (Opt, _) -> 1015 | erlang:error({bad_option, Opt}) 1016 | end, State, Opts); 1017 | init_state(revoke, DirURL, Cert, CertKey, Opts) -> 1018 | State = #state{command = revoke, 1019 | dir_url = check_url(DirURL), 1020 | domains = [], 1021 | contact = [], 1022 | cert = Cert, 1023 | cert_key = CertKey, 1024 | ca_certs = [], 1025 | end_time = current_time() + ?DEFAULT_TIMEOUT}, 1026 | lists:foldl( 1027 | fun({timeout, Timeout}, S) when is_integer(Timeout), Timeout > 0 -> 1028 | EndTime = current_time() + Timeout, 1029 | S#state{end_time = EndTime}; 1030 | ({debug_fun, Fun}, S) when is_function(Fun, 2) -> 1031 | S#state{debug_fun = Fun}; 1032 | (Opt, _) -> 1033 | erlang:error({bad_option, Opt}) 1034 | end, State, Opts). 1035 | 1036 | -spec find_location([{string(), string()}]) -> string() | undefined. 1037 | find_location(Hdrs) -> 1038 | find_location(Hdrs, undefined). 1039 | 1040 | -spec find_location([{string(), string()}], T) -> string() | T. 1041 | find_location(Hdrs, Default) -> 1042 | proplists:get_value("location", Hdrs, Default). 1043 | 1044 | -spec split_challenges([{domain(), p1_acme_codec:challenge_obj()}, ...]) -> 1045 | {Pending :: [{domain(), p1_acme_codec:challenge_obj()}], 1046 | InProgress :: [{domain(), p1_acme_codec:challenge_obj()}], 1047 | Valid :: [{domain(), p1_acme_codec:challenge_obj()}], 1048 | InValid ::[{domain(), p1_acme_codec:challenge_obj()}]}. 1049 | split_challenges(Challenges) -> 1050 | split_challenges(Challenges, [], [], [], []). 1051 | 1052 | split_challenges([{_, Challenge} = C|Cs], Pending, InProgress, Valid, Invalid) -> 1053 | case maps:get(status, Challenge) of 1054 | pending -> split_challenges(Cs, [C|Pending], InProgress, Valid, Invalid); 1055 | processing -> split_challenges(Cs, Pending, [C|InProgress], Valid, Invalid); 1056 | valid -> split_challenges(Cs, Pending, InProgress, [C|Valid], Invalid); 1057 | invalid -> split_challenges(Cs, Pending, InProgress, Valid, [C|Invalid]) 1058 | end; 1059 | split_challenges([], Pending, InProgress, Valid, Invalid) -> 1060 | {Pending, InProgress, Valid, Invalid}. 1061 | -------------------------------------------------------------------------------- /src/p1_acme_codec.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %%% @author Evgeny Khramtsov 3 | %%% @copyright (C) 2002-2025 ProcessOne, SARL. All Rights Reserved. 4 | %%% 5 | %%% Licensed under the Apache License, Version 2.0 (the "License"); 6 | %%% you may not use this file except in compliance with the License. 7 | %%% You may obtain a copy of the License at 8 | %%% 9 | %%% http://www.apache.org/licenses/LICENSE-2.0 10 | %%% 11 | %%% Unless required by applicable law or agreed to in writing, software 12 | %%% distributed under the License is distributed on an "AS IS" BASIS, 13 | %%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | %%% See the License for the specific language governing permissions and 15 | %%% limitations under the License. 16 | %%% 17 | %%%------------------------------------------------------------------- 18 | -module(p1_acme_codec). 19 | 20 | %% API 21 | -export([decode_dir_obj/1]). 22 | -export([decode_acc_obj/1]). 23 | -export([decode_order_obj/1]). 24 | -export([decode_auth_obj/1]). 25 | -export([decode_err_obj/1]). 26 | 27 | -type acme_error() :: accountDoesNotExist | 28 | alreadyRevoked | 29 | badCSR | 30 | badNonce | 31 | badPublicKey | 32 | badRevocationReason | 33 | badSignatureAlgorithm | 34 | caa | 35 | compound | 36 | connection | 37 | dns | 38 | externalAccountRequired | 39 | incorrectResponse | 40 | invalidContact | 41 | malformed | 42 | orderNotReady | 43 | rateLimited | 44 | rejectedIdentifier | 45 | serverInternal | 46 | tls | 47 | unauthorized | 48 | unsupportedContact | 49 | unsupportedIdentifier | 50 | userActionRequired | 51 | binary(). 52 | 53 | -type dir_obj() :: #{newNonce := binary(), 54 | newAccount := binary(), 55 | newOrder := binary(), 56 | revokeCert := binary(), 57 | keyChange := binary(), 58 | newAuthz => binary(), 59 | meta => 60 | #{caaIdentities => [binary()], 61 | termsOfService => binary(), 62 | website => binary(), 63 | externalAccountRequired => boolean()}}. 64 | 65 | -type acc_obj() :: #{status := valid | deactivated | revoked, 66 | contact => [binary()], 67 | termsOfServiceAgreed => boolean(), 68 | externalAccountBinding => _, 69 | orders => binary()}. 70 | 71 | -type order_obj() :: #{status := pending | ready | processing | valid | invalid, 72 | identifiers := [identifier_obj(), ...], 73 | authorizations := [binary()], 74 | finalize := binary(), 75 | expires => erlang:timestamp(), 76 | notBefore => erlang:timestamp(), 77 | notAfter => erlang:timestamp(), 78 | error => err_obj(), 79 | certificate => binary()}. 80 | 81 | -type auth_obj() :: #{identifier := identifier_obj(), 82 | status := pending | valid | invalid | 83 | deactivated | expired | revoked, 84 | expires => erlang:timestamp(), 85 | challenges => [challenge_obj(), ...], 86 | wildcard => boolean()}. 87 | 88 | -type err_obj() :: #{type := acme_error(), 89 | detail => binary(), 90 | status => pos_integer()}. 91 | 92 | -type identifier_obj() :: #{type := dns, value := binary()}. 93 | 94 | -type challenge_obj() :: #{type := binary(), 95 | url := binary(), 96 | status := pending | processing | valid | invalid, 97 | validated => erlang:timestamp(), 98 | token => binary(), 99 | error => err_obj()}. 100 | 101 | -export_type([acme_error/0, dir_obj/0, acc_obj/0, order_obj/0, auth_obj/0, 102 | err_obj/0, identifier_obj/0, challenge_obj/0]). 103 | 104 | %%%=================================================================== 105 | %%% API 106 | %%%=================================================================== 107 | -spec decode_dir_obj(map()) -> {ok, dir_obj()} | yconf:error_return(). 108 | decode_dir_obj(JSON) -> 109 | Validator = yconf:options( 110 | #{newNonce => yconf:url(), 111 | newAccount => yconf:url(), 112 | newOrder => yconf:url(), 113 | newAuthz => yconf:url(), 114 | revokeCert => yconf:url(), 115 | keyChange => yconf:url(), 116 | meta => yconf:options( 117 | #{caaIdentities => yconf:list(yconf:binary()), 118 | termsOfService => yconf:url(), 119 | website => yconf:url(), 120 | externalAccountRequired => yconf:bool(), 121 | '_' => yconf:any()}, 122 | [unique, {return, map}]), 123 | '_' => yconf:any()}, 124 | [unique, {return, map}, 125 | {required, [newNonce, newAccount, newOrder, 126 | revokeCert, keyChange]}]), 127 | decode(Validator, JSON). 128 | 129 | -spec decode_acc_obj(map()) -> {ok, acc_obj()} | yconf:error_return(). 130 | decode_acc_obj(JSON) -> 131 | Validator = yconf:options( 132 | #{status => yconf:enum([valid, deactivated, revoked]), 133 | contact => yconf:list(yconf:binary()), 134 | termsOfServiceAgreed => yconf:bool(), 135 | externalAccountBinding => yconf:any(), 136 | orders => yconf:url(), 137 | '_' => yconf:any()}, 138 | [unique, {return, map}, {required, [status]}]), 139 | decode(Validator, JSON). 140 | 141 | -spec decode_order_obj(map()) -> {ok, order_obj()} | yconf:error_return(). 142 | decode_order_obj(JSON) -> 143 | Validator = yconf:options( 144 | #{status => yconf:enum([pending, ready, processing, valid, invalid]), 145 | expires => timestamp_validator(), 146 | identifiers => yconf:non_empty(yconf:list(identifier_validator())), 147 | notBefore => timestamp_validator(), 148 | notAfter => timestamp_validator(), 149 | error => err_obj_validator(), 150 | authorizations => yconf:non_empty(yconf:list(yconf:url())), 151 | finalize => yconf:url(), 152 | certificate => yconf:url(), 153 | '_' => yconf:any()}, 154 | [unique, {return, map}, 155 | {required, [status, identifiers, authorizations, finalize]}]), 156 | decode(Validator, JSON). 157 | 158 | -spec decode_auth_obj(map()) -> {ok, auth_obj()} | yconf:error_return(). 159 | decode_auth_obj(JSON) -> 160 | Validator = yconf:options( 161 | #{identifier => identifier_validator(), 162 | status => yconf:enum([pending, valid, invalid, 163 | deactivated, expired, revoked]), 164 | expires => timestamp_validator(), 165 | challenges => yconf:non_empty(yconf:list(challenge_validator())), 166 | wildcard => yconf:bool(), 167 | '_' => yconf:any()}, 168 | [unique, {return, map}, 169 | {required, [identifier, status]}]), 170 | decode(Validator, JSON). 171 | 172 | -spec decode_err_obj(map()) -> {ok, err_obj()} | yconf:error_return(). 173 | decode_err_obj(JSON) -> 174 | decode(err_obj_validator(), JSON). 175 | 176 | %%%=================================================================== 177 | %%% Internal functions 178 | %%%=================================================================== 179 | decode(Validator, JSON) -> 180 | yconf:validate(Validator, json_to_yaml(JSON)). 181 | 182 | json_to_yaml(M) when is_map(M) -> 183 | lists:filtermap( 184 | fun({Key, Val}) -> 185 | try binary_to_existing_atom(Key, latin1) of 186 | Opt -> {true, {Opt, json_to_yaml(Val)}} 187 | catch _:_ -> 188 | false 189 | end 190 | end, maps:to_list(M)); 191 | json_to_yaml(L) when is_list(L) -> 192 | lists:map(fun json_to_yaml/1, L); 193 | json_to_yaml(Term) -> 194 | Term. 195 | 196 | %%%=================================================================== 197 | %%% Validators 198 | %%%=================================================================== 199 | identifier_validator() -> 200 | yconf:options( 201 | #{type => yconf:enum([dns]), 202 | value => yconf:binary(), 203 | '_' => yconf:any()}, 204 | [unique, {return, map}, {required, [type, value]}]). 205 | 206 | err_obj_validator() -> 207 | yconf:options( 208 | #{type => acme_error_validator(), 209 | detail => yconf:binary(), 210 | status => yconf:pos_int(), 211 | '_' => yconf:any()}, 212 | [unique, {return, map}, {required, [type]}]). 213 | 214 | challenge_validator() -> 215 | yconf:options( 216 | #{type => yconf:binary(), 217 | url => yconf:url(), 218 | status => yconf:enum([pending, processing, valid, invalid]), 219 | validated => timestamp_validator(), 220 | token => yconf:binary(), 221 | error => err_obj_validator(), 222 | '_' => yconf:any()}, 223 | [unique, {return, map}, 224 | {required, [type, url, status]}]). 225 | 226 | timestamp_validator() -> 227 | fun(S) -> 228 | B = (yconf:binary())(S), 229 | try try_decode_timestamp(B) 230 | catch _:_ -> yconf:fail(?MODULE, {bad_timestamp, B}) 231 | end 232 | end. 233 | 234 | acme_error_validator() -> 235 | fun(S) -> 236 | URL = (yconf:binary())(S), 237 | case URL of 238 | <<"urn:ietf:params:acme:error:", Type/binary>> -> 239 | (yconf:enum(acme_errors()))(Type); 240 | _ -> 241 | URL 242 | end 243 | end. 244 | 245 | -spec acme_errors() -> [acme_error()]. 246 | acme_errors() -> 247 | [accountDoesNotExist, 248 | alreadyRevoked, 249 | badCSR, 250 | badNonce, 251 | badPublicKey, 252 | badRevocationReason, 253 | badSignatureAlgorithm, 254 | caa, 255 | compound, 256 | connection, 257 | dns, 258 | externalAccountRequired, 259 | incorrectResponse, 260 | invalidContact, 261 | malformed, 262 | orderNotReady, 263 | rateLimited, 264 | rejectedIdentifier, 265 | serverInternal, 266 | tls, 267 | unauthorized, 268 | unsupportedContact, 269 | unsupportedIdentifier, 270 | userActionRequired]. 271 | 272 | try_decode_timestamp(<>) -> 274 | Date = {to_integer(Y, 1970, 9999), to_integer(Mo, 1, 12), to_integer(D, 1, 31)}, 275 | Time = {to_integer(H, 0, 23), to_integer(Mi, 0, 59), to_integer(S, 0, 59)}, 276 | {MS, {TZH, TZM}} = try_decode_fraction(T), 277 | Seconds = calendar:datetime_to_gregorian_seconds({Date, Time}) - 278 | calendar:datetime_to_gregorian_seconds({{1970,1,1}, {0,0,0}}) - 279 | TZH * 60 * 60 - TZM * 60, 280 | {Seconds div 1000000, Seconds rem 1000000, MS}. 281 | 282 | try_decode_fraction(<<$., T/binary>>) -> 283 | {match, [V]} = re:run(T, <<"^[0-9]+">>, [{capture, [0], list}]), 284 | Size = length(V), 285 | <<_:Size/binary, TZD/binary>> = T, 286 | {list_to_integer(string:left(V, 6, $0)), 287 | try_decode_tzd(TZD)}; 288 | try_decode_fraction(TZD) -> 289 | {0, try_decode_tzd(TZD)}. 290 | 291 | try_decode_tzd(<<$Z>>) -> 292 | {0, 0}; 293 | try_decode_tzd(<<$-, H:2/binary, $:, M:2/binary>>) -> 294 | {-1 * to_integer(H, 0, 12), to_integer(M, 0, 59)}; 295 | try_decode_tzd(<<$+, H:2/binary, $:, M:2/binary>>) -> 296 | {to_integer(H, 0, 12), to_integer(M, 0, 59)}. 297 | 298 | to_integer(S, Min, Max) -> 299 | case binary_to_integer(S) of 300 | I when I >= Min, I =< Max -> 301 | I 302 | end. 303 | --------------------------------------------------------------------------------