%%%------------------------------------------------------------------- %%% @author Evgeny Khramtsov %%% @copyright (C) 2002-2021 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.