processone-p1_acme-bab7d7b/0000775000232200023220000000000015160727623016330 5ustar debalancedebalanceprocessone-p1_acme-bab7d7b/plugins/0000775000232200023220000000000015160727623020011 5ustar debalancedebalanceprocessone-p1_acme-bab7d7b/plugins/override_deps_versions.erl0000664000232200023220000000770315160727623025306 0ustar debalancedebalance-module(override_deps_versions). -export([preprocess/2, 'pre_update-deps'/2, new_replace/1, new_replace/0]). preprocess(Config, _Dirs) -> update_deps(Config). update_deps(Config) -> LocalDeps = rebar_config:get_local(Config, deps, []), TopDeps = case rebar_config:get_xconf(Config, top_deps, []) of [] -> LocalDeps; Val -> Val end, Config2 = rebar_config:set_xconf(Config, top_deps, TopDeps), NewDeps = lists:map(fun({Name, _, _} = Dep) -> case lists:keyfind(Name, 1, TopDeps) of false -> Dep; TopDep -> TopDep end end, LocalDeps), %io:format("LD ~p~n", [LocalDeps]), %io:format("TD ~p~n", [TopDeps]), Config3 = rebar_config:set(Config2, deps, NewDeps), {ok, Config3, []}. 'pre_update-deps'(Config, _Dirs) -> {ok, Config2, _} = update_deps(Config), case code:is_loaded(old_rebar_config) of false -> {_, Beam, _} = code:get_object_code(rebar_config), NBeam = rename(Beam, old_rebar_config), code:load_binary(old_rebar_config, "blank", NBeam), replace_mod(Beam); _ -> ok end, {ok, Config2}. new_replace() -> old_rebar_config:new(). new_replace(Config) -> NC = old_rebar_config:new(Config), {ok, Conf, _} = update_deps(NC), Conf. replace_mod(Beam) -> {ok, {_, [{exports, Exports}]}} = beam_lib:chunks(Beam, [exports]), Funcs = lists:filtermap( fun({module_info, _}) -> false; ({Name, Arity}) -> Args = args(Arity), Call = case Name of new -> [erl_syntax:application( erl_syntax:abstract(override_deps_versions), erl_syntax:abstract(new_replace), Args)]; _ -> [erl_syntax:application( erl_syntax:abstract(old_rebar_config), erl_syntax:abstract(Name), Args)] end, {true, erl_syntax:function(erl_syntax:abstract(Name), [erl_syntax:clause(Args, none, Call)])} end, Exports), Forms0 = ([erl_syntax:attribute(erl_syntax:abstract(module), [erl_syntax:abstract(rebar_config)])] ++ Funcs), Forms = [erl_syntax:revert(Form) || Form <- Forms0], %io:format("--------------------------------------------------~n" % "~s~n", % [[erl_pp:form(Form) || Form <- Forms]]), {ok, Mod, Bin} = compile:forms(Forms, [report, export_all]), code:purge(rebar_config), {module, Mod} = code:load_binary(rebar_config, "mock", Bin). args(0) -> []; args(N) -> [arg(N) | args(N-1)]. arg(N) -> erl_syntax:variable(list_to_atom("A"++integer_to_list(N))). rename(BeamBin0, Name) -> BeamBin = replace_in_atab(BeamBin0, Name), update_form_size(BeamBin). %% Replace the first atom of the atom table with the new name replace_in_atab(<<"Atom", CnkSz0:32, Cnk:CnkSz0/binary, Rest/binary>>, Name) -> replace_first_atom(<<"Atom">>, Cnk, CnkSz0, Rest, latin1, Name); replace_in_atab(<<"AtU8", CnkSz0:32, Cnk:CnkSz0/binary, Rest/binary>>, Name) -> replace_first_atom(<<"AtU8">>, Cnk, CnkSz0, Rest, unicode, Name); replace_in_atab(<>, Name) -> <>. replace_first_atom(CnkName, Cnk, CnkSz0, Rest, Encoding, Name) -> <> = Cnk, NumPad0 = num_pad_bytes(CnkSz0), <<_:NumPad0/unit:8, NextCnks/binary>> = Rest, NameBin = atom_to_binary(Name, Encoding), NameSz = byte_size(NameBin), CnkSz = CnkSz0 + NameSz - NameSz0, NumPad = num_pad_bytes(CnkSz), <>. %% Calculate the number of padding bytes that have to be added for the %% BinSize to be an even multiple of ?beam_num_bytes_alignment. num_pad_bytes(BinSize) -> case 4 - (BinSize rem 4) of 4 -> 0; N -> N end. %% Update the size within the top-level form update_form_size(<<"FOR1", _OldSz:32, Rest/binary>> = Bin) -> Sz = size(Bin) - 8, <<"FOR1", Sz:32, Rest/binary>>. processone-p1_acme-bab7d7b/README.md0000664000232200023220000000102415160727623017604 0ustar debalancedebalance# Erlang ACME client (RFC8555) This is implementation of ACME v1 protocol in ERlang language [![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) [![Coverage Status](https://coveralls.io/repos/processone/p1_acme/badge.svg?branch=master&service=github)](https://coveralls.io/github/processone/p1_acme?branch=master) [![Hex version](https://img.shields.io/hexpm/v/p1_acme.svg "Hex version")](https://hex.pm/packages/p1_acme) processone-p1_acme-bab7d7b/rebar.config0000664000232200023220000000450115160727623020612 0ustar debalancedebalance%%%------------------------------------------------------------------- %%% @author Evgeny Khramtsov %%% @copyright (C) 2002-2026 ProcessOne, SARL. All Rights Reserved. %%% %%% Licensed under the Apache License, Version 2.0 (the "License"); %%% you may not use this file except in compliance with the License. %%% You may obtain a copy of the License at %%% %%% http://www.apache.org/licenses/LICENSE-2.0 %%% %%% Unless required by applicable law or agreed to in writing, software %%% distributed under the License is distributed on an "AS IS" BASIS, %%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %%% See the License for the specific language governing permissions and %%% limitations under the License. %%% %%%------------------------------------------------------------------- {erl_opts, [debug_info, {src_dirs, ["src"]}, {i, "include"}, {if_version_below, "27", {d, 'OTP_BELOW_27'}}, {platform_define, "^(R|1|20)", deprecated_stacktrace}]}. {deps, [{idna, "~> 7.1", {git, "https://github.com/benoitc/erlang-idna", {tag, "7.1.0"}}}, {if_version_below, "27", {jiffy, "~> 1.1.1", {git, "https://github.com/davisp/jiffy", {tag, "1.1.1"}}} }, {if_version_below, "26", {jose, "1.11.10", {git, "https://github.com/potatosalad/erlang-jose", {tag, "1.11.10"}}}, {jose, "~> 1.11.12", {git, "https://github.com/potatosalad/erlang-jose", {tag, "1.11.12"}}} }, {yconf, "~> 1.0.22", {git, "https://github.com/processone/yconf", {tag, "1.0.23"}}} ]}. {cover_enabled, true}. {cover_export_enabled, true}. {coveralls_coverdata , "_build/test/cover/eunit.coverdata"}. {coveralls_service_name , "github"}. {xref_checks, [undefined_function_calls, undefined_functions, deprecated_function_calls, deprecated_functions]}. {overrides, [{del, [{erl_opts, [warnings_as_errors]}]}]}. {if_not_rebar3, {plugins, [override_deps_versions]}}. %% Compiling Jose 1.11.10 with Erlang/OTP 27.0 throws warnings on public_key deprecated functions {if_rebar3, {overrides, [{del, jose, [{erl_opts, [warnings_as_errors]}]}]}}. {profiles, [{test, [{erl_opts, [{src_dirs, ["test"]}]}]}]}. {dialyzer, [{if_version_below, "27", {plt_extra_apps, [jiffy]}}]}. %% Local Variables: %% mode: erlang %% End: %% vim: set filetype=erlang tabstop=8: processone-p1_acme-bab7d7b/CHANGELOG.md0000664000232200023220000000443215160727623020144 0ustar debalancedebalance# Version 1.0.31 * Updating yconf to version 1.0.23. * Update idna to version 7.1 * Remove jiffy dependency for =' 3) all: src src: $(REBAR) get-deps $(REBAR) compile clean: $(REBAR) clean distclean: clean rm -f config.status rm -f config.log rm -rf autom4te.cache rm -rf _build rm -rf deps rm -rf ebin rm -f rebar.lock rm -f test/*.beam rm -rf priv rm -f vars.config rm -f erl_crash.dump rm -f compile_commands.json rm -rf dialyzer xref: all $(REBAR) xref ifeq "$(IS_REBAR3)" "1" dialyzer: $(REBAR) dialyzer else deps := $(wildcard deps/*/ebin) dialyzer/erlang.plt: @mkdir -p dialyzer @dialyzer --build_plt --output_plt dialyzer/erlang.plt \ -o dialyzer/erlang.log --apps kernel stdlib erts ssl \ inets public_key crypto; \ status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi dialyzer/deps.plt: @mkdir -p dialyzer @dialyzer --build_plt --output_plt dialyzer/deps.plt \ -o dialyzer/deps.log $(deps); \ status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi dialyzer/p1_acme.plt: @mkdir -p dialyzer @dialyzer --build_plt --output_plt dialyzer/p1_acme.plt \ -o dialyzer/p1_acme.log ebin; \ status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi erlang_plt: dialyzer/erlang.plt @dialyzer --plt dialyzer/erlang.plt --check_plt -o dialyzer/erlang.log; \ status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi deps_plt: dialyzer/deps.plt @dialyzer --plt dialyzer/deps.plt --check_plt -o dialyzer/deps.log; \ status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi p1_acme_plt: dialyzer/p1_acme.plt @dialyzer --plt dialyzer/p1_acme.plt --check_plt -o dialyzer/p1_acme.log; \ status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi dialyzer: erlang_plt deps_plt p1_acme_plt @dialyzer --plts dialyzer/*.plt --no_check_plt \ --get_warnings -Wunmatched_returns -o dialyzer/error.log ebin; \ status=$$? ; if [ $$status -ne 2 ]; then exit $$status; else exit 0; fi endif check-syntax: gcc -o nul -S ${CHK_SOURCES} .PHONY: clean src xref all dialyzer erlang_plt deps_plt p1_acme_plt processone-p1_acme-bab7d7b/rebar.config.script0000664000232200023220000001727215160727623022126 0ustar debalancedebalance%%%---------------------------------------------------------------------- %%% File : rebar.config.script %%% Author : Mickael Remond %%% Purpose : Rebar build script. Compliant with rebar and rebar3. %%% Created : 24 Nov 2015 by Mickael Remond %%% %%% Copyright (C) 2002-2026 ProcessOne, SARL. All Rights Reserved. %%% %%% Licensed under the Apache License, Version 2.0 (the "License"); %%% you may not use this file except in compliance with the License. %%% You may obtain a copy of the License at %%% %%% http://www.apache.org/licenses/LICENSE-2.0 %%% %%% Unless required by applicable law or agreed to in writing, software %%% distributed under the License is distributed on an "AS IS" BASIS, %%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %%% See the License for the specific language governing permissions and %%% limitations under the License. %%% %%%---------------------------------------------------------------------- Cfg = case file:consult(filename:join([filename:dirname(SCRIPT),"vars.config"])) of {ok, Terms} -> Terms; _Err -> [] end ++ [{cflags, "-g -O2 -Wall"}, {ldflags, "-lpthread"}, {with_gcov, "false"}], {cflags, CfgCFlags} = lists:keyfind(cflags, 1, Cfg), {ldflags, CfgLDFlags} = lists:keyfind(ldflags, 1, Cfg), {with_gcov, CfgWithGCov} = lists:keyfind(with_gcov, 1, Cfg), IsRebar3 = case application:get_key(rebar, vsn) of {ok, VSN} -> [VSN1 | _] = string:tokens(VSN, "-"), [Maj|_] = string:tokens(VSN1, "."), (list_to_integer(Maj) >= 3); undefined -> lists:keymember(mix, 1, application:loaded_applications()) end, SysVer = erlang:system_info(otp_release), ProcessSingleVar = fun(F, Var, Tail) -> case F(F, [Var], []) of [] -> Tail; [Val] -> [Val | Tail] end end, ProcessVars = fun(_F, [], Acc) -> lists:reverse(Acc); (F, [{Type, Ver, Value} | Tail], Acc) when Type == if_version_above orelse Type == if_version_below -> Include = if Type == if_version_above -> SysVer > Ver; true -> SysVer < Ver end, if Include -> F(F, Tail, ProcessSingleVar(F, Value, Acc)); true -> F(F, Tail, Acc) end; (F, [{Type, Ver, Value, ElseValue} | Tail], Acc) when Type == if_version_above orelse Type == if_version_below -> Include = if Type == if_version_above -> SysVer > Ver; true -> SysVer < Ver end, if Include -> F(F, Tail, ProcessSingleVar(F, Value, Acc)); true -> F(F, Tail, ProcessSingleVar(F, ElseValue, Acc)) end; (F, [{Type, Var, Value} | Tail], Acc) when Type == if_var_true orelse Type == if_var_false -> Flag = Type == if_var_true, case proplists:get_bool(Var, Cfg) of V when V == Flag -> F(F, Tail, ProcessSingleVar(F, Value, Acc)); _ -> F(F, Tail, Acc) end; (F, [{Type, Value} | Tail], Acc) when Type == if_rebar3 orelse Type == if_not_rebar3 -> Flag = Type == if_rebar3, case IsRebar3 == Flag of true -> F(F, Tail, ProcessSingleVar(F, Value, Acc)); _ -> F(F, Tail, Acc) end; (F, [{Type, Var, Match, Value} | Tail], Acc) when Type == if_var_match orelse Type == if_var_no_match -> case proplists:get_value(Var, Cfg) of V when V == Match -> F(F, Tail, ProcessSingleVar(F, Value, Acc)); _ -> F(F, Tail, Acc) end; (F, [{if_have_fun, MFA, Value} | Tail], Acc) -> {Mod, Fun, Arity} = MFA, code:ensure_loaded(Mod), case erlang:function_exported(Mod, Fun, Arity) of true -> F(F, Tail, ProcessSingleVar(F, Value, Acc)); false -> F(F, Tail, Acc) end; (F, [Other1 | Tail1], Acc) -> F(F, Tail1, [F(F, Other1, []) | Acc]); (F, Val, Acc) when is_tuple(Val) -> list_to_tuple(F(F, tuple_to_list(Val), Acc)); (_F, Other2, _Acc) -> Other2 end, ModCfg0 = fun(F, Cfg, [Key|Tail], Op, Default) -> {OldVal,PartCfg} = case lists:keytake(Key, 1, Cfg) of {value, {_, V1}, V2} -> {V1, V2}; false -> {if Tail == [] -> Default; true -> [] end, Cfg} end, case Tail of [] -> [{Key, Op(OldVal)} | PartCfg]; _ -> [{Key, F(F, OldVal, Tail, Op, Default)} | PartCfg] end end, ModCfg = fun(Cfg, Keys, Op, Default) -> ModCfg0(ModCfg0, Cfg, Keys, Op, Default) end, ModCfgS = fun(Cfg, Keys, Val) -> ModCfg0(ModCfg0, Cfg, Keys, fun(_V) -> Val end, "") end, FilterConfig = fun(F, Cfg, [{Path, true, ModFun, Default} | Tail]) -> F(F, ModCfg0(ModCfg0, Cfg, Path, ModFun, Default), Tail); (F, Cfg, [_ | Tail]) -> F(F, Cfg, Tail); (F, Cfg, []) -> Cfg end, AppendStr = fun(Append) -> fun("") -> Append; (Val) -> Val ++ " " ++ Append end end, AppendList = fun(Append) -> fun(Val) -> Val ++ Append end end, % Convert our rich deps syntax to rebar2 format: % https://github.com/rebar/rebar/wiki/Dependency-management Rebar2DepsFilter = fun(DepsList) -> lists:map(fun({DepName, _HexVersion, Source}) -> {DepName, ".*", Source} end, DepsList) end, % Convert our rich deps syntax to rebar3 version definition format: % https://rebar3.org/docs/configuration/dependencies/#dependency-version-handling % https://hexdocs.pm/elixir/Version.html Rebar3DepsFilter = fun(DepsList) -> lists:map(fun({DepName, HexVersion, {git, _, {tag, GitVersion}} = Source}) -> case HexVersion == ".*" of true -> {DepName, GitVersion}; false -> {DepName, HexVersion} end; ({DepName, _HexVersion, Source}) -> {DepName, ".*", Source} end, DepsList) end, GlobalDepsFilter = fun(Deps) -> DepNames = lists:map(fun({DepName, _, _}) -> DepName; ({DepName, _}) -> DepName end, Deps), lists:filtermap(fun(Dep) -> case code:lib_dir(Dep) of {error, _} -> {true,"Unable to locate dep '"++atom_to_list(Dep)++"' in system deps."}; _ -> false end end, DepNames) end, GithubConfig = case {os:getenv("GITHUB_ACTIONS"), os:getenv("GITHUB_TOKEN")} of {"true", Token} when is_list(Token) -> CONFIG1 = [{coveralls_repo_token, Token}, {coveralls_service_job_id, os:getenv("GITHUB_RUN_ID")}, {coveralls_commit_sha, os:getenv("GITHUB_SHA")}, {coveralls_service_number, os:getenv("GITHUB_RUN_NUMBER")}], case os:getenv("GITHUB_EVENT_NAME") =:= "pull_request" andalso string:tokens(os:getenv("GITHUB_REF"), "/") of [_, "pull", PRNO, _] -> [{coveralls_service_pull_request, PRNO} | CONFIG1]; _ -> CONFIG1 end; _ -> [] end, Rules = [ {[deps], (not IsRebar3), Rebar2DepsFilter, []}, {[deps], IsRebar3, Rebar3DepsFilter, []}, {[plugins], IsRebar3, AppendList([pc]), []}, {[plugins], os:getenv("COVERALLS") == "true", AppendList([{coveralls, {git, "https://github.com/processone/coveralls-erl.git", {branch, "addjsonfile"}}} ]), []}, {[deps], os:getenv("USE_GLOBAL_DEPS") /= false, GlobalDepsFilter, []} ], Config = FilterConfig(FilterConfig, ProcessVars(ProcessVars, CONFIG, []), Rules) ++ GithubConfig, %io:format("Rules:~n~p~n~nCONFIG:~n~p~n~nConfig:~n~p~n", [Rules, CONFIG, Config]), Config. %% Local Variables: %% mode: erlang %% End: %% vim: set filetype=erlang tabstop=8: processone-p1_acme-bab7d7b/src/0000775000232200023220000000000015160727623017117 5ustar debalancedebalanceprocessone-p1_acme-bab7d7b/src/p1_acme.erl0000664000232200023220000010770715160727623021144 0ustar debalancedebalance%%%------------------------------------------------------------------- %%% @author Evgeny Khramtsov %%% @copyright (C) 2002-2026 ProcessOne, SARL. All Rights Reserved. %%% %%% Licensed under the Apache License, Version 2.0 (the "License"); %%% you may not use this file except in compliance with the License. %%% You may obtain a copy of the License at %%% %%% http://www.apache.org/licenses/LICENSE-2.0 %%% %%% Unless required by applicable law or agreed to in writing, software %%% distributed under the License is distributed on an "AS IS" BASIS, %%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %%% See the License for the specific language governing permissions and %%% limitations under the License. %%% %%%------------------------------------------------------------------- -module(p1_acme). %% API -export([start/0, stop/0]). -export([issue/2, issue/3, issue/4]). -export([revoke/3, revoke/4]). -export([generate_key/1]). -export([generate_csr/2]). -export([format_error/1]). %% OTP Application API -export([start/2]). -include_lib("public_key/include/public_key.hrl"). -define(DER_NULL, <<5, 0>>). -define(DEFAULT_TIMEOUT, timer:minutes(1)). -define(RETRY_TIMEOUT, 500). -define(DEBUG(Fmt, Args), case State#state.debug_fun of undefined -> ok; _ -> (State#state.debug_fun)(Fmt, Args) end). -record(state, {command :: command(), dir_url :: string(), domains :: [domain()], contact :: [binary()], end_time :: integer(), challenge_type :: undefined | binary(), account :: undefined | {priv_key(), undefined | string()}, cert_type :: undefined | cert_type(), cert :: undefined | cert(), cert_key :: undefined | priv_key(), ca_certs :: [cert()], nonce :: undefined | binary(), new_acc_url :: undefined | string(), new_nonce_url :: undefined | string(), new_order_url :: undefined | string(), revoke_url :: undefined | string(), order_url :: undefined | string(), debug_fun :: undefined | debug_fun(), challenge_fun :: undefined | challenge_fun()}). -type state() :: #state{}. -type command() :: issue | revoke. -type priv_key() :: public_key:private_key(). -type pub_key() :: #'RSAPublicKey'{} | #'ECPoint'{}. -type cert() :: #'OTPCertificate'{}. -type domain() :: string(). %% UTF-8 charlist() -type cert_type() :: ec | rsa. -type challenge_data() :: [#{domain := domain(), token := binary(), key := binary()}]. -type challenge_fun() :: fun((challenge_data()) -> any()). -type challenge_type() :: 'http-01'. -type debug_fun() :: fun((string(), list()) -> _). -type http_req_fun() :: fun((state()) -> {http_method(), _}). -type option() :: {timeout, pos_integer()} | {debug_fun, debug_fun()}. -type issue_option() :: {contact, [binary() | string()]} | {cert_type, cert_type()} | {cert_key, priv_key()} | {ca_certs, [cert()]} | {challenge_type, challenge_type()} | {challenge_fun, challenge_fun()} | option(). -type revoke_option() :: option(). -type http_method() :: get | post | head. -type http_header() :: {string(), string()}. -type http_json() :: {100..699, [http_header()], map()}. -type http_bin() :: {100..699, [http_header()], binary()}. -type bad_cert_reason() :: cert_expired | invalid_issuer | invalid_signature | name_not_permitted | missing_basic_constraint | invalid_key_usage | selfsigned_peer | unknown_ca | empty_chain | key_mismatch. -type error_reason() :: {codec_error, yconf:error_reason(), yconf:ctx(), map()} | {http_error, term()} | {challenge_failed, domain(), undefined | p1_acme_codec:err_obj()} | {unsupported_challenges, domain(), [string()]} | {bad_pem, string()} | {bad_der, string()} | {bad_json, binary()} | {bad_cert, bad_cert_reason()} | {problem_report, p1_acme_codec:err_obj()}. -type error_return() :: {error, error_reason()}. -type issue_return() :: {ok, #{acc_key := priv_key(), cert_key := priv_key(), cert_chain := [cert(), ...], validation_result => valid | {bad_cert, bad_cert_reason()}}} | error_return(). -type revoke_return() :: ok | error_return(). -type acme_return() :: issue_return() | revoke_return(). -export_type([error_reason/0, issue_return/0, revoke_return/0, challenge_data/0]). %%%=================================================================== %%% OTP Application API %%%=================================================================== start(_StartType, _StartArgs) -> application:start(inets), inets:start(httpc, [{profile, ?MODULE}]), httpc:set_options([{ipfamily, inet6fb4}], ?MODULE), {ok, self()}. %%%=================================================================== %%% API %%%=================================================================== start() -> start(normal, []), case application:ensure_all_started(?MODULE) of {ok, _} -> ok; Err -> Err end. stop() -> application:stop(?MODULE). -spec issue(binary() | string(), [domain()]) -> issue_return(). issue(DirURL, Domains) -> issue(DirURL, Domains, generate_key(ec), []). -spec issue(binary() | string(), [domain()], priv_key() | [issue_option()]) -> issue_return(). issue(DirURL, Domains, Opts) when is_list(Opts) -> issue(DirURL, Domains, generate_key(ec), Opts); issue(DirURL, Domains, AccKey) -> issue(DirURL, Domains, AccKey, []). -spec issue(binary() | string(), [domain()], priv_key(), [issue_option()]) -> issue_return(). issue(DirURL, Domains, AccKey, Opts) -> State = init_state(issue, DirURL, Domains, AccKey, Opts), request_directory(State). -spec revoke(binary() | string(), cert(), priv_key()) -> revoke_return(). revoke(DirURL, Cert, CertKey) -> revoke(DirURL, Cert, CertKey, []). -spec revoke(binary() | string(), cert(), priv_key(), [revoke_option()]) -> revoke_return(). revoke(DirURL, Cert, CertKey, Opts) -> State = init_state(revoke, DirURL, Cert, CertKey, Opts), request_directory(State). -spec format_error(error_reason()) -> string(). format_error({codec_error, Reason, Ctx, _JSON}) -> format("Codec error: Failed to validate JSON object: ~s " "(not an ACMEv2 compatible server?)", [yconf:format_error(Reason, Ctx)]); format_error({http_error, Err}) -> "HTTP error: " ++ case Err of {code, Code, ""} -> "unexpected status code: " ++ integer_to_list(Code); {code, Code, Slogan} -> format("~ts (~B)", [Slogan, Code]); {inet, Reason} -> "transport failure: " ++ format_inet_error(Reason); {could_not_parse_as_http, _} -> "received malformed HTTP packet"; {missing_header, Header} -> format("missing '~s' header", [Header]); {unexpected_content_type, Type} -> format("unexpected content type: ~ts", [Type]); _ -> format("~p", [Err]) end; format_error({challenge_failed, Domain, undefined}) -> format("Challenge failed for domain ~ts", [Domain]); format_error({challenge_failed, Domain, ErrObj}) -> format("Challenge failed for domain ~ts: ~ts", [Domain, format_problem_report(ErrObj)]); format_error({unsupported_challenges, Domain, Types}) -> format("ACME server offered unsupported challenges for domain ~ts: ~s", [Domain, string:join(Types, ", ")]); format_error({bad_pem, URL}) -> format("Failed to decode PEM certificate chain obtained from ~s", [URL]); format_error({bad_der, URL}) -> format("Failed to decode ASN.1 DER certificate in the chain obtained from ~s", [URL]); format_error({bad_json, Data}) -> format("Failed to decode JSON: ~s", [Data]); format_error({bad_cert, Reason}) -> format_bad_cert_error(Reason); format_error({problem_report, ErrObj}) -> format_problem_report(ErrObj); format_error(Other) -> format("Unrecognized error: ~p", [Other]). -spec format_bad_cert_error(bad_cert_reason()) -> string(). format_bad_cert_error(empty_chain) -> "certificate chain is empty"; format_bad_cert_error(key_mismatch) -> "certificate's public key doesn't match local private key"; format_bad_cert_error(cert_expired) -> "certificate in the chain is no longer valid as its expiration date has passed"; format_bad_cert_error(invalid_issuer) -> "certificate issuer name does not match the name of the " "issuer certificate"; format_bad_cert_error(invalid_signature) -> "certificate in the chain was not signed by its issuer certificate"; format_bad_cert_error(name_not_permitted) -> "invalid Subject Alternative Name extension"; format_bad_cert_error(missing_basic_constraint) -> "certificate, required to have the basic constraints extension, " "does not have a basic constraints extension"; format_bad_cert_error(invalid_key_usage) -> "certificate key is used in an invalid way according " "to the key-usage extension"; format_bad_cert_error(selfsigned_peer) -> "self-signed certificate in the chain"; format_bad_cert_error(unknown_ca) -> "certificate chain is signed by unknown CA". -spec format_inet_error(atom()) -> string(). format_inet_error(Reason) when is_atom(Reason) -> case inet:format_error(Reason) of "unknown POSIX error" -> atom_to_list(Reason); Txt -> Txt end. -spec format_problem_report(p1_acme_codec:err_obj()) -> string(). format_problem_report(#{type := Type, detail := Detail}) -> format("ACME server reported: ~ts (error type: ~s)", [Detail, Type]); format_problem_report(#{type := Type}) -> format("ACME server responded with ~s error", [Type]). -spec format(string(), list()) -> list(). format(Fmt, Args) -> lists:flatten(io_lib:format(Fmt, Args)). %%%=================================================================== %%% Internal functions %%%=================================================================== %%%=================================================================== %%% ACME Requests %%%=================================================================== -spec request_directory(state()) -> acme_return(). request_directory(State) -> Req = fun(S) -> {get, {S#state.dir_url, []}} end, case http_request(State, Req) of {ok, Reply, State1} -> handle_directory_response(Reply, State1); Err -> Err end. -spec request_new_nonce(state()) -> acme_return(). request_new_nonce(State) -> Req = fun(S) -> {head, {S#state.new_nonce_url, []}} end, case http_request(State, Req) of {ok, Reply, State1} -> handle_nonce_response(Reply, State1); Err -> Err end. -spec request_new_account(state()) -> issue_return(). request_new_account(State) -> Req = fun(S) -> Body = #{<<"termsOfServiceAgreed">> => true, <<"contact">> => S#state.contact}, JoseJSON = jose_json(S, Body, S#state.new_acc_url), {post, {S#state.new_acc_url, [], "application/jose+json", JoseJSON}} end, case http_request(State, Req) of {ok, Reply, State1} -> handle_account_response(Reply, State1); Err -> Err end. -spec request_new_order(state()) -> issue_return(). request_new_order(State) -> Req = fun(S) -> Body = #{<<"identifiers">> => [#{<<"type">> => <<"dns">>, <<"value">> => list_to_binary(idna:to_ascii(Domain))} || Domain <- S#state.domains]}, JoseJSON = jose_json(S, Body, S#state.new_order_url), {post, {S#state.new_order_url, [], "application/jose+json", JoseJSON}} end, case http_request(State, Req) of {ok, Reply, State1} -> handle_order_response(Reply, State1); Err -> Err end. -spec request_domain_auth(state(), [string()]) -> {ok, state(), [{domain(), p1_acme_codec:challenge_obj()}]} | error_return(). request_domain_auth(State, AuthURLs) -> request_domain_auth(State, AuthURLs, []). -spec request_domain_auth(state(), [string()], [{domain(), p1_acme_codec:challenge_obj()}]) -> {ok, state(), [{domain(), p1_acme_codec:challenge_obj()}]} | error_return(). request_domain_auth(State, [URL|URLs], Challenges) -> Req = fun(S) -> JoseJSON = jose_json(S, <<>>, URL), {post, {URL, [], "application/jose+json", JoseJSON}} end, case http_request(State, Req) of {ok, Reply, State1} -> case handle_domain_auth_response(Reply, State1) of {ok, Challenge} -> request_domain_auth(State1, URLs, [Challenge|Challenges]); Err -> Err end; Err -> Err end; request_domain_auth(State, [], Challenges) -> {ok, State, Challenges}. -spec request_challenges(state(), [{domain(), p1_acme_codec:challenge_obj()}]) -> issue_return(). request_challenges(State, Challenges) -> {Pending, _InProgress, _Valid, Invalid} = split_challenges(Challenges), case Invalid of [{Domain, Challenge}|_] -> Reason = maps:get(error, Challenge, undefined), mk_error({challenge_failed, Domain, Reason}); [] -> case Pending of [_|_] -> Args = lists:map( fun({Domain, #{token := Token}}) -> #{domain => Domain, key => auth_key(State, Token), token => Token} end, Pending), (State#state.challenge_fun)(Args); [] -> ok end, case lists:foldl( fun(_, {error, _} = Err) -> Err; ({_, C}, S) -> request_challenge(S, C) end, State, Pending) of {error, Reason} -> {error, Reason}; State1 -> poll(State1) end end. -spec request_challenge(state(), p1_acme_codec:challenge_obj()) -> state() | error_return(). request_challenge(State, #{url := URL0}) -> URL = binary_to_list(URL0), Req = fun(S) -> JoseJSON = jose_json(S, #{}, URL), {post, {URL, [], "application/jose+json", JoseJSON}} end, case http_request(State, Req) of {ok, _Reply, State1} -> State1; Err -> Err end. -spec request_certificate(state(), string()) -> issue_return(). request_certificate(State, URL) -> {DerCSR, State1} = generate_csr(State), Body = #{<<"csr">> => base64url_encode(DerCSR)}, Req = fun(S) -> JoseJSON = jose_json(S, Body, URL), {post, {URL, [], "application/jose+json", JoseJSON}} end, case http_request(State1, Req) of {ok, Reply, State2} -> handle_order_response(Reply, State2); Err -> Err end. -spec revoke_certificate(state()) -> revoke_return(). revoke_certificate(#state{revoke_url = URL, cert_key = CertKey, cert = Cert} = State) -> DerCert = public_key:pkix_encode('OTPCertificate', Cert, otp), Body = #{<<"certificate">> => base64url_encode(DerCert)}, State1 = State#state{account = {CertKey, undefined}}, Req = fun(S) -> JoseJSON = jose_json(S, Body, URL), {post, {URL, [], "application/jose+json", JoseJSON}} end, case http_request(State1, Req) of {ok, _, _} -> ok; Err -> Err end. -spec request_pem_file(state(), string()) -> issue_return(). request_pem_file(State, URL) -> Req = fun(S) -> JoseJSON = jose_json(S, <<>>, URL), {post, {URL, [], "application/jose+json", JoseJSON}} end, case http_request(State, Req) of {ok, Reply, State1} -> handle_pem_file_response(Reply, URL, State1); Err -> Err end. -spec poll(state()) -> issue_return(). poll(State) -> poll(State, ?RETRY_TIMEOUT). -spec poll(state(), non_neg_integer()) -> issue_return(). poll(#state{order_url = URL} = State, Timeout) -> Req = fun(S) -> JoseJSON = jose_json(S, <<>>, URL), {post, {URL, [], "application/jose+json", JoseJSON}} end, case http_request(State, Req) of {ok, Reply, State1} -> handle_poll_response(Reply, State1, Timeout); Err -> Err end. %%%=================================================================== %%% Response processing %%%=================================================================== -spec handle_directory_response(http_json(), state()) -> acme_return(). handle_directory_response({_, _Hdrs, JSON}, State) -> case p1_acme_codec:decode_dir_obj(JSON) of {ok, #{newNonce := NonceURL, newAccount := AccURL, newOrder := OrderURL, revokeCert := RevokeURL}} -> State1 = State#state{new_nonce_url = binary_to_list(NonceURL), new_acc_url = binary_to_list(AccURL), new_order_url = binary_to_list(OrderURL), revoke_url = binary_to_list(RevokeURL)}, request_new_nonce(State1); Err -> mk_codec_error(Err, JSON) end. -spec handle_nonce_response(http_json(), state()) -> acme_return(). handle_nonce_response({_, Hdrs, _}, State) -> case lists:keyfind("replay-nonce", 1, Hdrs) of {_, Nonce} -> State1 = State#state{nonce = iolist_to_binary(Nonce)}, case State1#state.command of issue -> request_new_account(State1); revoke -> revoke_certificate(State1) end; false -> mk_http_error({missing_header, 'Replay-Nonce'}) end. -spec handle_account_response(http_json(), state()) -> issue_return(). handle_account_response({_, Hdrs, JSON}, State) -> case find_location(Hdrs) of undefined -> mk_http_error({missing_header, 'Location'}); AccURL -> case p1_acme_codec:decode_acc_obj(JSON) of {ok, _} -> {AccKey, _} = State#state.account, State1 = State#state{account = {AccKey, AccURL}}, request_new_order(State1); Err -> mk_codec_error(Err, JSON) end end. -spec handle_order_response(http_json(), state()) -> issue_return(). handle_order_response({_, Hdrs, JSON}, State) -> case find_location(Hdrs, State#state.order_url) of undefined -> mk_http_error({missing_header, 'Location'}); OrderURL -> case p1_acme_codec:decode_order_obj(JSON) of {ok, #{status := ready, finalize := FinURL}} -> request_certificate(State, binary_to_list(FinURL)); {ok, #{status := valid, certificate := CertURL}} -> request_pem_file(State, binary_to_list(CertURL)); {ok, #{authorizations := AuthURLs}} -> State1 = State#state{order_url = OrderURL}, case request_domain_auth( State1, lists:map(fun binary_to_list/1, AuthURLs)) of {ok, State2, Challenges} -> request_challenges(State2, Challenges); Err -> Err end; Err -> mk_codec_error(Err, JSON) end end. -spec handle_domain_auth_response(http_json(), state()) -> {ok, {domain(), p1_acme_codec:challenge_obj()}} | error_return(). handle_domain_auth_response({_, _Hdrs, JSON}, State) -> case p1_acme_codec:decode_auth_obj(JSON) of {ok, #{challenges := Challenges, identifier := #{value := D}}} -> Domain = idna:to_unicode(binary_to_list(D)), case lists:dropwhile( fun(#{type := T}) -> T /= State#state.challenge_type end, Challenges) of [Challenge|_] -> {ok, {Domain, Challenge}}; [] -> Types = [binary_to_list(maps:get(type, C)) || C <- Challenges], mk_error({unsupported_challenges, Domain, Types}) end; Err -> mk_codec_error(Err, JSON) end. -spec handle_poll_response(http_json(), state(), non_neg_integer()) -> issue_return(). handle_poll_response({_, _, JSON} = Response, State, Timeout) -> case p1_acme_codec:decode_order_obj(JSON) of {ok, #{status := Status}} when Status == pending; Status == processing -> Timeout1 = min(Timeout, get_timeout(State)), timer:sleep(Timeout1), poll(State, Timeout1*2); {ok, _} -> handle_order_response(Response, State); Err -> mk_codec_error(Err, JSON) end. -spec handle_pem_file_response(http_bin(), string(), state()) -> issue_return(). handle_pem_file_response({_, _, CertPEM}, URL, #state{cert_key = CertKey, ca_certs = CaCerts, account = {AccKey, _}}) -> try lists:map( fun({'Certificate', DER, not_encrypted}) -> DER end, public_key:pem_decode(CertPEM)) of DERs -> try lists:map( fun(DER) -> public_key:pkix_decode_cert(DER, otp) end, DERs) of [] -> mk_error({bad_cert, empty_chain}); CertChain -> {SortedCertChain, SortedDERs} = sort_cert_chain(CertChain, DERs), Ret = #{acc_key => AccKey, cert_key => CertKey, cert_chain => SortedCertChain}, Ret1 = case CaCerts of [] -> Ret; _ -> Ret#{validation_result => validate_cert_chain( SortedCertChain, SortedDERs, CertKey, CaCerts)} end, {ok, Ret1} catch _:_ -> mk_error({bad_der, URL}) end catch _:_ -> mk_error({bad_pem, URL}) end. %%%=================================================================== %%% HTTP request %%%=================================================================== -spec http_request(state(), http_req_fun()) -> {ok, http_json() | http_bin(), state()} | error_return(). http_request(State, ReqFun) -> http_request(State, ReqFun, ?RETRY_TIMEOUT). -spec http_request(state(), http_req_fun(), non_neg_integer()) -> {ok, http_json() | http_bin(), state()} | error_return(). http_request(State, ReqFun, RetryTimeout) -> case get_timeout(State) of 0 -> mk_http_error(etimedout); Timeout -> {Method, URL} = Request = ReqFun(State), ?DEBUG("HTTP request: ~p", [Request]), case httpc:request(Method, URL, [{timeout, infinity}, {connect_timeout, infinity}], [{body_format, binary}, {sync, false}], ?MODULE) of {ok, Ref} -> ReqTimeout = min(timer:seconds(10), Timeout), receive {http, {Ref, Response}} -> ?DEBUG("HTTP response: ~p", [Response]), handle_http_response( ReqFun, Response, State, RetryTimeout) after ReqTimeout -> ?DEBUG("HTTP request timeout", []), httpc:cancel_request(Ref, ?MODULE), http_request(State, ReqFun, RetryTimeout) end; {error, WTF} -> mk_http_error(WTF) end end. -spec http_retry(state(), http_req_fun(), non_neg_integer(), error_reason()) -> {ok, http_json() | http_bin(), state()} | error_return(). http_retry(State, ReqFun, RetryTimeout, Reason) -> case {need_retry(Reason), get_timeout(State)} of {true, Timeout} when Timeout > RetryTimeout -> timer:sleep(RetryTimeout), http_request(State, ReqFun, RetryTimeout*2); _ -> mk_error(Reason) end. -spec need_retry(error_reason()) -> boolean(). need_retry({http_error, {inet, Reason}}) -> case Reason of ehostdown -> true; ehostunreach -> true; enetdown -> true; enetreset -> true; enetunreach -> true; etimedout -> true; erefused -> true; econnrefused -> true; econnreset -> true; _ -> false end; need_retry({http_error, {code, Code, _}}) when Code >= 500, Code < 600 -> true; need_retry({problem_report, #{type := Type}}) when Type == badNonce; Type == serverInternal -> true; need_retry({problem_report, #{status := Code}}) when Code >= 500, Code < 600 -> true; need_retry(_) -> false. %%%=================================================================== %%% HTTP response processing %%%=================================================================== -spec handle_http_response(http_req_fun(), {{_, 100..699, string()}, [http_header()], binary()} | term(), state(), non_neg_integer()) -> {ok, http_json() | http_bin(), state()} | error_return(). handle_http_response(ReqFun, {{_, Code, Slogan}, Hdrs, Body}, State, RetryTimeout) -> case lists:keyfind("content-type", 1, Hdrs) of {_, Type} when Type == "application/problem+json"; Type == "application/json" -> State1 = update_nonce(Hdrs, State), try json_decode_maps(Body) of JSON when Type == "application/json" -> {ok, {Code, Hdrs, JSON}, State1}; JSON when Type == "application/problem+json" -> case p1_acme_codec:decode_err_obj(JSON) of {ok, ErrObj} -> http_retry(State1, ReqFun, RetryTimeout, {problem_report, ErrObj}); Err -> mk_codec_error(Err, JSON) end catch _:_ -> mk_error({bad_json, Body}) end; {_, Type} when Code >= 200, Code < 300 -> case Type of "application/pem-certificate-chain" -> {ok, {Code, Hdrs, Body}, State}; _ -> mk_http_error({unexpected_content_type, Type}) end; false when Code >= 200, Code < 300 -> case Body of <<>> -> {ok, {Code, Hdrs, #{}}, State}; _ -> mk_http_error({missing_header, 'Content-Type'}) end; _ when Code >= 500, Code < 600 -> http_retry(State, ReqFun, RetryTimeout, prep_http_error({code, Code, Slogan})); _ -> mk_http_error({code, Code, Slogan}) end; handle_http_response(ReqFun, {error, Reason}, State, RetryTimeout) -> http_retry(State, ReqFun, RetryTimeout, prep_http_error(Reason)); handle_http_response(ReqFun, Term, State, RetryTimeout) -> http_retry(State, ReqFun, RetryTimeout, prep_http_error(Term)). prep_http_error({failed_connect, List} = Reason) when is_list(List) -> {http_error, case lists:keyfind(inet, 1, List) of {_, _, Why} when is_atom(Why) -> {inet, case Why of timeout -> etimedout; closed -> econnreset; _ -> Why end}; _ -> Reason end}; prep_http_error(socket_closed_remotely) -> {http_error, {inet, econnreset}}; prep_http_error(Reason) -> {http_error, Reason}. -spec update_nonce([http_header()], state()) -> state(). update_nonce(Hdrs, State) -> case lists:keyfind("replay-nonce", 1, Hdrs) of {_, Nonce} -> State#state{nonce = iolist_to_binary(Nonce)}; false -> State end. %%%=================================================================== %%% Crypto stuff %%%=================================================================== -spec generate_key(cert_type()) -> priv_key(). generate_key(ec) -> public_key:generate_key({namedCurve, secp256r1}); generate_key(rsa) -> public_key:generate_key({rsa, 2048, 65537}). -spec generate_csr([domain(), ...], priv_key()) -> #'CertificationRequest'{}. generate_csr([_|_] = Domains, PrivKey) -> SignAlgoOID = signature_algorithm(PrivKey), PubKey = pubkey_from_privkey(PrivKey), {DigestType, _} = public_key:pkix_sign_types(SignAlgoOID), DerParams = der_params(PrivKey), DerSAN = public_key:der_encode( 'SubjectAltName', [{dNSName, idna:to_ascii(Domain)} || Domain <- Domains]), Extns = [#'Extension'{extnID = ?'id-ce-subjectAltName', critical = false, extnValue = DerSAN}], DerExtnReq = public_key:der_encode('ExtensionRequest', Extns), Attribute = #'Attribute'{type = ?'pkcs-9-at-extensionRequest', values = [{asn1_OPENTYPE, DerExtnReq}]}, SubjPKInfo = #'CertificationRequestInfo_subjectPKInfo'{ subjectPublicKey = subject_pubkey(PubKey), algorithm = #'CertificationRequestInfo_subjectPKInfo_algorithm'{ algorithm = algorithm(PrivKey), parameters = {asn1_OPENTYPE, DerParams}}}, CsrInfo = #'CertificationRequestInfo'{ version = v1, subject = {rdnSequence, []}, subjectPKInfo = SubjPKInfo, attributes = [Attribute]}, DerCsrInfo = public_key:der_encode('CertificationRequestInfo', CsrInfo), Signature = public_key:sign(DerCsrInfo, DigestType, PrivKey), #'CertificationRequest'{ certificationRequestInfo = CsrInfo, signatureAlgorithm = #'CertificationRequest_signatureAlgorithm'{ algorithm = SignAlgoOID}, signature = Signature}. -spec generate_csr(state()) -> {binary(), state()}. generate_csr(#state{domains = Domains, cert_type = Type, cert_key = Key} = State) -> CertKey = case Key of undefined -> generate_key(Type); _ -> Key end, CSR = generate_csr(Domains, CertKey), ?DEBUG("CSR = ~p", [CSR]), {public_key:der_encode('CertificationRequest', CSR), State#state{cert_type = cert_type(CertKey), cert_key = CertKey}}. -spec cert_type(priv_key()) -> cert_type(). cert_type(#'RSAPrivateKey'{}) -> rsa; cert_type(#'ECPrivateKey'{}) -> ec. signature_algorithm(#'ECPrivateKey'{}) -> ?'ecdsa-with-SHA256'; signature_algorithm(#'RSAPrivateKey'{}) -> ?'sha256WithRSAEncryption'. algorithm(#'ECPrivateKey'{}) -> ?'id-ecPublicKey'; algorithm(#'RSAPrivateKey'{}) -> ?'rsaEncryption'. -spec pubkey_from_privkey(priv_key()) -> pub_key(). pubkey_from_privkey(#'RSAPrivateKey'{modulus = Modulus, publicExponent = Exp}) -> #'RSAPublicKey'{modulus = Modulus, publicExponent = Exp}; pubkey_from_privkey(#'ECPrivateKey'{publicKey = Key}) -> #'ECPoint'{point = Key}. -spec subject_pubkey(pub_key()) -> binary(). subject_pubkey(#'ECPoint'{point = Point}) -> Point; subject_pubkey(#'RSAPublicKey'{} = Key) -> public_key:der_encode('RSAPublicKey', Key). -spec der_params(priv_key()) -> binary(). der_params(#'ECPrivateKey'{parameters = Params}) -> public_key:der_encode('EcpkParameters', Params); der_params(_) -> ?DER_NULL. -spec pubkey_from_cert(cert()) -> pub_key(). pubkey_from_cert(Cert) -> TBSCert = Cert#'OTPCertificate'.tbsCertificate, PubKeyInfo = TBSCert#'OTPTBSCertificate'.subjectPublicKeyInfo, SubjPubKey = PubKeyInfo#'OTPSubjectPublicKeyInfo'.subjectPublicKey, case PubKeyInfo#'OTPSubjectPublicKeyInfo'.algorithm of #'PublicKeyAlgorithm'{ algorithm = ?'rsaEncryption'} -> SubjPubKey; #'PublicKeyAlgorithm'{ algorithm = ?'id-ecPublicKey'} -> SubjPubKey end. -spec validate_cert_chain([cert(), ...], [binary(), ...], priv_key(), [cert()]) -> valid | {bad_cert, bad_cert_reason()}. validate_cert_chain([Cert|_] = Certs, DerCerts, PrivKey, CaCerts) -> case pubkey_from_privkey(PrivKey) == pubkey_from_cert(Cert) of false -> {bad_cert, key_mismatch}; true -> Last = lists:last(Certs), case find_issuer_cert(Last, CaCerts) of {ok, CaCert} -> case public_key:pkix_path_validation( CaCert, lists:reverse(DerCerts), []) of {ok, _} -> valid; {error, {bad_cert, _} = Reason} -> Reason end; error -> case public_key:pkix_is_self_signed(Last) of true -> {bad_cert, selfsigned_peer}; false -> {bad_cert, unknown_ca} end end end. -spec sort_cert_chain([cert()], [binary()]) -> {[cert()], [binary()]}. sort_cert_chain(Certs, DERs) -> lists:unzip( lists:sort( fun({Cert1, _}, {Cert2, _}) -> public_key:pkix_is_issuer(Cert1, Cert2) end, lists:zip(Certs, DERs))). -spec find_issuer_cert(cert(), [cert()]) -> {ok, cert()} | error. find_issuer_cert(Cert, [IssuerCert|IssuerCerts]) -> case public_key:pkix_is_issuer(Cert, IssuerCert) of true -> {ok, IssuerCert}; false -> find_issuer_cert(Cert, IssuerCerts) end; find_issuer_cert(_Cert, []) -> error. %%%=================================================================== %%% JOSE idiotism %%%=================================================================== -spec jose_json(state(), binary() | map(), binary() | string()) -> binary(). jose_json(State, JSON, URL) when is_map(JSON) -> jose_json(State, encode_json(JSON), URL); jose_json(#state{account = {Key, AccURL}, nonce = Nonce} = State, Data, URL) -> PrivKey = jose_jwk:from_key(convert_key_version_jose(Key)), PubKey = jose_jwk:to_public(PrivKey), AlgMap = case jose_jwk:signer(PrivKey) of M when is_record(Key, 'RSAPrivateKey') -> M#{<<"alg">> => <<"RS256">>}; M -> M end, JwsMap0 = #{<<"nonce">> => Nonce, <<"url">> => iolist_to_binary(URL)}, JwsMap = case AccURL of undefined -> {_, BinaryPubKey} = jose_jwk:to_binary(PubKey), PubKeyJson = json_decode_maps(BinaryPubKey), JwsMap0#{<<"jwk">> => PubKeyJson}; _ -> JwsMap0#{<<"kid">> => iolist_to_binary(AccURL)} end, JwsObj = jose_jws:from(maps:merge(JwsMap, AlgMap)), ?DEBUG("JOSE payload: ~s~nJOSE protected: ~p", [Data, JwsObj]), {_, JoseJSON} = jose_jws:sign(PrivKey, Data, JwsObj), encode_json(JoseJSON). -spec auth_key(state(), binary()) -> binary(). auth_key(#state{account = {PrivKey, _}}, Token) -> Thumbprint = jose_jwk:thumbprint(jose_jwk:from_key(convert_key_version_jose(PrivKey))), <>. -spec encode_json(map()) -> binary(). encode_json(JSON) -> json_encode(JSON). -ifdef(OTP_BELOW_27). json_encode(Term) -> iolist_to_binary(jiffy:encode(Term)). json_decode_maps(Bin) -> jiffy:decode(Bin, [return_maps]). -else. json_encode(Term) -> iolist_to_binary(json:encode(Term)). json_decode_maps(Bin) -> json:decode(Bin). -endif. %% Erlang/OTP 28 generates this, but Jose 1.11.10 doesn't support it convert_key_version_jose(#'ECPrivateKey'{version = ecPrivkeyVer1} = Key) -> Key#'ECPrivateKey'{version = 1}; convert_key_version_jose(Key) -> Key. %%%=================================================================== %%% Misc %%%=================================================================== -spec mk_http_error(term()) -> error_return(). mk_http_error(Reason) -> mk_error({http_error, Reason}). -spec mk_codec_error(yconf:error_return(), map()) -> error_return(). mk_codec_error({error, Reason, Ctx}, JSON) -> mk_error({codec_error, Reason, Ctx, JSON}). -spec mk_error(error_reason()) -> error_return(). mk_error(Reason) -> {error, Reason}. -spec current_time() -> integer(). current_time() -> erlang:monotonic_time(millisecond). -spec get_timeout(state()) -> non_neg_integer(). get_timeout(#state{end_time = EndTime}) -> max(0, EndTime - current_time()). -spec check_url(binary() | string()) -> string(). check_url(S) -> case yconf:validate(yconf:url(), iolist_to_binary(S)) of {ok, URL} -> binary_to_list(URL); _ -> erlang:error(badarg, [S]) end. -spec init_state(issue, binary() | string(), [domain()], priv_key(), [issue_option()]) -> state(); (revoke, binary() | string(), cert(), priv_key(), [revoke_option()]) -> state(). init_state(issue, DirURL, Domains, AccKey, Opts) -> State = #state{command = issue, dir_url = check_url(DirURL), domains = Domains, account = {AccKey, undefined}, contact = [], cert_type = ec, ca_certs = [], challenge_type = <<"http-01">>, end_time = current_time() + ?DEFAULT_TIMEOUT}, lists:foldl( fun({timeout, Timeout}, S) when is_integer(Timeout), Timeout > 0 -> EndTime = current_time() + Timeout, S#state{end_time = EndTime}; ({contact, Cs}, S) when is_list(Cs) -> S#state{contact = lists:map(fun iolist_to_binary/1, Cs)}; ({cert_type, T}, S) when T == ec; T == rsa -> S#state{cert_type = T}; ({cert_key, K}, S) -> S#state{cert_key = K}; ({ca_certs, L}, S) when is_list(L) -> S#state{ca_certs = L}; ({challenge_type, 'http-01'}, S) -> S#state{challenge_type = <<"http-01">>}; ({challenge_fun, Fun}, S) when is_function(Fun, 1) -> S#state{challenge_fun = Fun}; ({debug_fun, Fun}, S) when is_function(Fun, 2) -> S#state{debug_fun = Fun}; (Opt, _) -> erlang:error({bad_option, Opt}) end, State, Opts); init_state(revoke, DirURL, Cert, CertKey, Opts) -> State = #state{command = revoke, dir_url = check_url(DirURL), domains = [], contact = [], cert = Cert, cert_key = CertKey, ca_certs = [], end_time = current_time() + ?DEFAULT_TIMEOUT}, lists:foldl( fun({timeout, Timeout}, S) when is_integer(Timeout), Timeout > 0 -> EndTime = current_time() + Timeout, S#state{end_time = EndTime}; ({debug_fun, Fun}, S) when is_function(Fun, 2) -> S#state{debug_fun = Fun}; (Opt, _) -> erlang:error({bad_option, Opt}) end, State, Opts). -spec find_location([{string(), string()}]) -> string() | undefined. find_location(Hdrs) -> find_location(Hdrs, undefined). -spec find_location([{string(), string()}], T) -> string() | T. find_location(Hdrs, Default) -> proplists:get_value("location", Hdrs, Default). -spec split_challenges([{domain(), p1_acme_codec:challenge_obj()}, ...]) -> {Pending :: [{domain(), p1_acme_codec:challenge_obj()}], InProgress :: [{domain(), p1_acme_codec:challenge_obj()}], Valid :: [{domain(), p1_acme_codec:challenge_obj()}], InValid ::[{domain(), p1_acme_codec:challenge_obj()}]}. split_challenges(Challenges) -> split_challenges(Challenges, [], [], [], []). split_challenges([{_, Challenge} = C|Cs], Pending, InProgress, Valid, Invalid) -> case maps:get(status, Challenge) of pending -> split_challenges(Cs, [C|Pending], InProgress, Valid, Invalid); processing -> split_challenges(Cs, Pending, [C|InProgress], Valid, Invalid); valid -> split_challenges(Cs, Pending, InProgress, [C|Valid], Invalid); invalid -> split_challenges(Cs, Pending, InProgress, Valid, [C|Invalid]) end; split_challenges([], Pending, InProgress, Valid, Invalid) -> {Pending, InProgress, Valid, Invalid}. base64url_encode(String) -> unicode:characters_to_binary(jose_base64url:encode(String)). processone-p1_acme-bab7d7b/src/p1_acme.app.src0000664000232200023220000000251315160727623021715 0ustar debalancedebalance%%%------------------------------------------------------------------- %%% @author Evgeny Khramtsov %%% @copyright (C) 2002-2026 ProcessOne, SARL. All Rights Reserved. %%% %%% Licensed under the Apache License, Version 2.0 (the "License"); %%% you may not use this file except in compliance with the License. %%% You may obtain a copy of the License at %%% %%% http://www.apache.org/licenses/LICENSE-2.0 %%% %%% Unless required by applicable law or agreed to in writing, software %%% distributed under the License is distributed on an "AS IS" BASIS, %%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %%% See the License for the specific language governing permissions and %%% limitations under the License. %%% %%%------------------------------------------------------------------- {application, p1_acme, [{description, "ACME client"}, {vsn, "1.0.31"}, {modules, []}, {registered, []}, {applications, [kernel, stdlib, crypto, inets, public_key, ssl, idna, jose, yconf]}, {env, []}, {mod, {p1_acme, []}}, %% hex.pm packaging: {licenses, ["Apache 2.0"]}, {links, [{"Github", "https://github.com/processone/p1_acme"}]} ]}. %% Local Variables: %% mode: erlang %% End: %% vim: set filetype=erlang tabstop=8: processone-p1_acme-bab7d7b/src/p1_acme_codec.erl0000664000232200023220000002311615160727623022270 0ustar debalancedebalance%%%------------------------------------------------------------------- %%% @author Evgeny Khramtsov %%% @copyright (C) 2002-2026 ProcessOne, SARL. All Rights Reserved. %%% %%% Licensed under the Apache License, Version 2.0 (the "License"); %%% you may not use this file except in compliance with the License. %%% You may obtain a copy of the License at %%% %%% http://www.apache.org/licenses/LICENSE-2.0 %%% %%% Unless required by applicable law or agreed to in writing, software %%% distributed under the License is distributed on an "AS IS" BASIS, %%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %%% See the License for the specific language governing permissions and %%% limitations under the License. %%% %%%------------------------------------------------------------------- -module(p1_acme_codec). %% API -export([decode_dir_obj/1]). -export([decode_acc_obj/1]). -export([decode_order_obj/1]). -export([decode_auth_obj/1]). -export([decode_err_obj/1]). -type acme_error() :: accountDoesNotExist | alreadyRevoked | badCSR | badNonce | badPublicKey | badRevocationReason | badSignatureAlgorithm | caa | compound | connection | dns | externalAccountRequired | incorrectResponse | invalidContact | malformed | orderNotReady | rateLimited | rejectedIdentifier | serverInternal | tls | unauthorized | unsupportedContact | unsupportedIdentifier | userActionRequired | binary(). -type dir_obj() :: #{newNonce := binary(), newAccount := binary(), newOrder := binary(), revokeCert := binary(), keyChange := binary(), newAuthz => binary(), meta => #{caaIdentities => [binary()], termsOfService => binary(), website => binary(), externalAccountRequired => boolean()}}. -type acc_obj() :: #{status := valid | deactivated | revoked, contact => [binary()], termsOfServiceAgreed => boolean(), externalAccountBinding => _, orders => binary()}. -type order_obj() :: #{status := pending | ready | processing | valid | invalid, identifiers := [identifier_obj(), ...], authorizations := [binary()], finalize := binary(), expires => erlang:timestamp(), notBefore => erlang:timestamp(), notAfter => erlang:timestamp(), error => err_obj(), certificate => binary()}. -type auth_obj() :: #{identifier := identifier_obj(), status := pending | valid | invalid | deactivated | expired | revoked, expires => erlang:timestamp(), challenges => [challenge_obj(), ...], wildcard => boolean()}. -type err_obj() :: #{type := acme_error(), detail => binary(), status => pos_integer()}. -type identifier_obj() :: #{type := dns, value := binary()}. -type challenge_obj() :: #{type := binary(), url := binary(), status := pending | processing | valid | invalid, validated => erlang:timestamp(), token => binary(), error => err_obj()}. -export_type([acme_error/0, dir_obj/0, acc_obj/0, order_obj/0, auth_obj/0, err_obj/0, identifier_obj/0, challenge_obj/0]). %%%=================================================================== %%% API %%%=================================================================== -spec decode_dir_obj(map()) -> {ok, dir_obj()} | yconf:error_return(). decode_dir_obj(JSON) -> Validator = yconf:options( #{newNonce => yconf:url(), newAccount => yconf:url(), newOrder => yconf:url(), newAuthz => yconf:url(), revokeCert => yconf:url(), keyChange => yconf:url(), meta => yconf:options( #{caaIdentities => yconf:list(yconf:binary()), termsOfService => yconf:url(), website => yconf:url(), externalAccountRequired => yconf:bool(), '_' => yconf:any()}, [unique, {return, map}]), '_' => yconf:any()}, [unique, {return, map}, {required, [newNonce, newAccount, newOrder, revokeCert, keyChange]}]), decode(Validator, JSON). -spec decode_acc_obj(map()) -> {ok, acc_obj()} | yconf:error_return(). decode_acc_obj(JSON) -> Validator = yconf:options( #{status => yconf:enum([valid, deactivated, revoked]), contact => yconf:list(yconf:binary()), termsOfServiceAgreed => yconf:bool(), externalAccountBinding => yconf:any(), orders => yconf:url(), '_' => yconf:any()}, [unique, {return, map}, {required, [status]}]), decode(Validator, JSON). -spec decode_order_obj(map()) -> {ok, order_obj()} | yconf:error_return(). decode_order_obj(JSON) -> Validator = yconf:options( #{status => yconf:enum([pending, ready, processing, valid, invalid]), expires => timestamp_validator(), identifiers => yconf:non_empty(yconf:list(identifier_validator())), notBefore => timestamp_validator(), notAfter => timestamp_validator(), error => err_obj_validator(), authorizations => yconf:non_empty(yconf:list(yconf:url())), finalize => yconf:url(), certificate => yconf:url(), '_' => yconf:any()}, [unique, {return, map}, {required, [status, identifiers, authorizations, finalize]}]), decode(Validator, JSON). -spec decode_auth_obj(map()) -> {ok, auth_obj()} | yconf:error_return(). decode_auth_obj(JSON) -> Validator = yconf:options( #{identifier => identifier_validator(), status => yconf:enum([pending, valid, invalid, deactivated, expired, revoked]), expires => timestamp_validator(), challenges => yconf:non_empty(yconf:list(challenge_validator())), wildcard => yconf:bool(), '_' => yconf:any()}, [unique, {return, map}, {required, [identifier, status]}]), decode(Validator, JSON). -spec decode_err_obj(map()) -> {ok, err_obj()} | yconf:error_return(). decode_err_obj(JSON) -> decode(err_obj_validator(), JSON). %%%=================================================================== %%% Internal functions %%%=================================================================== decode(Validator, JSON) -> yconf:validate(Validator, json_to_yaml(JSON)). json_to_yaml(M) when is_map(M) -> lists:filtermap( fun({Key, Val}) -> try binary_to_existing_atom(Key, latin1) of Opt -> {true, {Opt, json_to_yaml(Val)}} catch _:_ -> false end end, maps:to_list(M)); json_to_yaml(L) when is_list(L) -> lists:map(fun json_to_yaml/1, L); json_to_yaml(Term) -> Term. %%%=================================================================== %%% Validators %%%=================================================================== identifier_validator() -> yconf:options( #{type => yconf:enum([dns]), value => yconf:binary(), '_' => yconf:any()}, [unique, {return, map}, {required, [type, value]}]). err_obj_validator() -> yconf:options( #{type => acme_error_validator(), detail => yconf:binary(), status => yconf:pos_int(), '_' => yconf:any()}, [unique, {return, map}, {required, [type]}]). challenge_validator() -> yconf:options( #{type => yconf:binary(), url => yconf:url(), status => yconf:enum([pending, processing, valid, invalid]), validated => timestamp_validator(), token => yconf:binary(), error => err_obj_validator(), '_' => yconf:any()}, [unique, {return, map}, {required, [type, url, status]}]). timestamp_validator() -> fun(S) -> B = (yconf:binary())(S), try try_decode_timestamp(B) catch _:_ -> yconf:fail(?MODULE, {bad_timestamp, B}) end end. acme_error_validator() -> fun(S) -> URL = (yconf:binary())(S), case URL of <<"urn:ietf:params:acme:error:", Type/binary>> -> (yconf:enum(acme_errors()))(Type); _ -> URL end end. -spec acme_errors() -> [acme_error()]. acme_errors() -> [accountDoesNotExist, alreadyRevoked, badCSR, badNonce, badPublicKey, badRevocationReason, badSignatureAlgorithm, caa, compound, connection, dns, externalAccountRequired, incorrectResponse, invalidContact, malformed, orderNotReady, rateLimited, rejectedIdentifier, serverInternal, tls, unauthorized, unsupportedContact, unsupportedIdentifier, userActionRequired]. try_decode_timestamp(<>) -> Date = {to_integer(Y, 1970, 9999), to_integer(Mo, 1, 12), to_integer(D, 1, 31)}, Time = {to_integer(H, 0, 23), to_integer(Mi, 0, 59), to_integer(S, 0, 59)}, {MS, {TZH, TZM}} = try_decode_fraction(T), Seconds = calendar:datetime_to_gregorian_seconds({Date, Time}) - calendar:datetime_to_gregorian_seconds({{1970,1,1}, {0,0,0}}) - TZH * 60 * 60 - TZM * 60, {Seconds div 1000000, Seconds rem 1000000, MS}. try_decode_fraction(<<$., T/binary>>) -> {match, [V]} = re:run(T, <<"^[0-9]+">>, [{capture, [0], list}]), Size = length(V), <<_:Size/binary, TZD/binary>> = T, {list_to_integer(string:left(V, 6, $0)), try_decode_tzd(TZD)}; try_decode_fraction(TZD) -> {0, try_decode_tzd(TZD)}. try_decode_tzd(<<$Z>>) -> {0, 0}; try_decode_tzd(<<$-, H:2/binary, $:, M:2/binary>>) -> {-1 * to_integer(H, 0, 12), to_integer(M, 0, 59)}; try_decode_tzd(<<$+, H:2/binary, $:, M:2/binary>>) -> {to_integer(H, 0, 12), to_integer(M, 0, 59)}. to_integer(S, Min, Max) -> case binary_to_integer(S) of I when I >= Min, I =< Max -> I end.