From 8d5a5a0e0d96e0510d3e4e274204e8da1ea8b52b Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 1 May 2026 14:43:53 +0200 Subject: [PATCH] support ARI by default --- acme.sh | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 95 insertions(+), 11 deletions(-) diff --git a/acme.sh b/acme.sh index dbed1359..9e37a406 100755 --- a/acme.sh +++ b/acme.sh @@ -4072,8 +4072,8 @@ makednspersistvalue() { _info "" _info "Add the following DNS TXT record to enable persistent DNS validation:" _info "" - _info "$(printf 'TXT domain: %s' "$(__green "$_txt_name")")" - _info "$(printf 'TXT value: %s' "$(__green "\"$_mdpv_ca_name$_txt_suffix\"")")" + _info "$(printf 'TXT persist domain:%s' "$(__green "$_txt_name")")" + _info "$(printf 'TXT persist value :%s' "$(__green "\"$_mdpv_ca_name$_txt_suffix\"")")" _info "" return 0 fi @@ -4103,8 +4103,8 @@ makednspersistvalue() { for _id in $_caaids; do [ -z "$_id" ] && continue _info "" - _info "$(printf 'TXT domain: %s' "$(__green "$_txt_name")")" - _info "$(printf 'TXT value : %s' "$(__green "\"$_id$_txt_suffix\"")")" + _info "$(printf 'TXT persist domain:%s' "$(__green "$_txt_name")")" + _info "$(printf 'TXT persist value :%s' "$(__green "\"$_id$_txt_suffix\"")")" done _info "" } @@ -4790,13 +4790,41 @@ issue() { if [ "$_certificate_profile" ]; then _newOrderObj="$_newOrderObj,\"profile\": \"$_certificate_profile\"" fi + + # RFC 9773 Section 5: include "replaces" only when this is an actual + # renewal (--renew path), the CA advertises renewalInfo, and a prior + # cert exists. --issue (even with --force) is not a renewal per RFC 9773 + # which speaks of "a clear predecessor certificate" issued by this CA. + _replaces_certID="" + if [ "$_ACME_IS_RENEW" = "1" ] && [ "$ACME_RENEWAL_INFO" ] && [ -f "$CERT_PATH" ]; then + _replaces_certID="$(_getARICertID "$CERT_PATH")" + _debug "Adding ARI replaces" "$_replaces_certID" + fi + _debug "STEP 1, Ordering a Certificate" - if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderObj}"; then + _newOrderReplacesObj="$_newOrderObj" + if [ "$_replaces_certID" ]; then + _newOrderReplacesObj="$_newOrderObj,\"replaces\": \"$_replaces_certID\"" + fi + if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderReplacesObj}"; then _err "Error creating new order." _clearup _on_issue_err "$_post_hook" return 1 fi + # RFC 9773 Section 5 only defines the "alreadyReplaced" error, but real CAs + # (Let's Encrypt) may also reject with a malformed error if the prior cert + # was issued by a different issuer / different CA. Retry without "replaces" + # whenever the failure mentions ARI or the replaces field. + if [ "$_replaces_certID" ] && { _contains "$response" "alreadyReplaced" || _contains "$response" "'replaces'" || _contains "$response" "ARI"; }; then + _info "ARI 'replaces' rejected by CA, retrying newOrder without 'replaces'." + if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderObj}"; then + _err "Error creating new order." + _clearup + _on_issue_err "$_post_hook" + return 1 + fi + fi if _contains "$response" "invalid"; then if echo "$response" | _normalizeJson | grep '"status":"invalid"' >/dev/null 2>&1; then _err "Create new order with invalid status." @@ -5581,6 +5609,30 @@ $_authorizations_map" Le_NextRenewTime=$(_math "$Le_NextRenewTime" - 86400) Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") fi + + # RFC 9773 ARI: if the CA exposes renewalInfo, override Le_NextRenewTime + # with a time picked at random within the suggestedWindow. This both gives + # the CA full control over renewal scheduling and disperses renewals across + # the network so all clients don't hit the CA at the same instant. + if [ "$ACME_RENEWAL_INFO" ] && [ -f "$CERT_PATH" ] && [ -z "$_notAfter" ]; then + _ari_resp_new="$(_get_ARI "$CERT_PATH")" + _debug2 "_ari_resp_new" "$_ari_resp_new" + _ari_start_new="$(echo "$_ari_resp_new" | _egrep_o '"start" *: *"[^"]*' | sed 's/.*"//')" + _ari_end_new="$(echo "$_ari_resp_new" | _egrep_o '"end" *: *"[^"]*' | sed 's/.*"//')" + if [ "$_ari_start_new" ] && [ "$_ari_end_new" ]; then + _ari_start_t_new="$(_date2time "$(echo "$_ari_start_new" | sed 's/\.[0-9]*//')")" + _ari_end_t_new="$(_date2time "$(echo "$_ari_end_new" | sed 's/\.[0-9]*//')")" + if [ "$_ari_start_t_new" ] && [ "$_ari_end_t_new" ] && [ "$_ari_end_t_new" -gt "$_ari_start_t_new" ]; then + _ari_window=$(_math "$_ari_end_t_new" - "$_ari_start_t_new") + _ari_offset=$(_math "$(_time)" % "$_ari_window") + Le_NextRenewTime=$(_math "$_ari_start_t_new" + "$_ari_offset") + Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") + _info "ARI suggestedWindow: $(__green "$_ari_start_new") to $(__green "$_ari_end_new")" + _info "Next renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")" + fi + fi + fi + _savedomainconf "Le_NextRenewTimeStr" "$Le_NextRenewTimeStr" _savedomainconf "Le_NextRenewTime" "$Le_NextRenewTime" @@ -5676,7 +5728,31 @@ renew() { _debug2 "initpath again." _initpath "$Le_Domain" "$_isEcc" - if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then + # ARI (RFC 9773): fetch the CA's suggestedWindow on every renewal check. + # If the window has started, renew now even if Le_NextRenewTime is in the future. + _ari_should_renew="" + if [ -z "$FORCE" ] && [ -f "$CERT_PATH" ]; then + if _initAPI && [ "$ACME_RENEWAL_INFO" ]; then + _ari_resp="$(_get_ARI "$CERT_PATH")" + _debug2 "_ari_resp" "$_ari_resp" + _ari_start="$(echo "$_ari_resp" | _egrep_o '"start" *: *"[^"]*' | sed 's/.*"//')" + _ari_end="$(echo "$_ari_resp" | _egrep_o '"end" *: *"[^"]*' | sed 's/.*"//')" + _debug "ARI suggestedWindow.start" "$_ari_start" + _debug "ARI suggestedWindow.end" "$_ari_end" + if [ "$_ari_start" ]; then + _ari_start_t="$(_date2time "$(echo "$_ari_start" | sed 's/\.[0-9]*//')")" + _debug "_ari_start_t" "$_ari_start_t" + if [ "$_ari_start_t" ] && [ "$(_time)" -ge "$_ari_start_t" ]; then + _info "ARI suggestedWindow has started ($(__green "$_ari_start")), proceeding with renewal." + _ari_should_renew="1" + else + _info "ARI suggestedWindow starts at: $(__green "$_ari_start")" + fi + fi + fi + fi + + if [ -z "$FORCE" ] && [ -z "$_ari_should_renew" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then _info "Skipping. Next renewal time is: $(__green "$Le_NextRenewTimeStr")" _info "Add '$(__red '--force')' to force renewal." if [ -z "$_ACME_IN_RENEWALL" ]; then @@ -6698,21 +6774,29 @@ _getSerial() { } #cert -_get_ARI() { +#Compute the ARI/replaces certID for a cert: base64url(AKI).base64url(Serial) +#per RFC 9773 Section 4.1. +_getARICertID() { _cert="$1" _aki=$(_getAKI "$_cert") _ser=$(_getSerial "$_cert") _debug2 "_aki" "$_aki" _debug2 "_ser" "$_ser" - _akiurl="$(echo "$_aki" | _h2b | _base64 | tr -d = | _url_encode)" + _akiurl="$(echo "$_aki" | _h2b | _base64 | _url_replace)" _debug2 "_akiurl" "$_akiurl" - _serurl="$(echo "$_ser" | _h2b | _base64 | tr -d = | _url_encode)" + _serurl="$(echo "$_ser" | _h2b | _base64 | _url_replace)" _debug2 "_serurl" "$_serurl" - _ARI_URL="$ACME_RENEWAL_INFO/$_akiurl.$_serurl" - _get "$_ARI_URL" + printf "%s.%s" "$_akiurl" "$_serurl" +} +#cert +_get_ARI() { + _cert="$1" + _ari_certID="$(_getARICertID "$_cert")" + _ARI_URL="$ACME_RENEWAL_INFO/$_ari_certID" + _get "$_ARI_URL" } # Detect profile file if not specified as environment variable