From dd30327df9461726a2a5d9b7044116c3cb282b29 Mon Sep 17 00:00:00 2001 From: Alexander Lindberg Date: Thu, 3 Jul 2025 10:55:09 +0300 Subject: [PATCH 1/2] Add resume capability after using --delay It was previously not possible to reliably use the -delayed switch and expect DNS challenge tokens to be remembered for a second run. This commit adds logic to save and load the needed state - %callback_data in le.pl and select attributes in LE.PM. In the script it is done directly. In the module it is done using two new methods. _save_state and _load_state. Data is saved to disk using the Storable package. A new command line parameter, -resume, has been added to facilitate the use of the stored data for a second pass. Additionally, the DNS challenge tokens are now saved to disk for easier integration with external registration methods. Previously, this could only be done by enabling file logging and extracting the tokens using regex. Additionally, the OpenSSL option use_pkcs1_padding in LE::_set_key was removed, as the use is discouraged (https://github.com/tomato42/marvin-toolkit/blob/master/README.md) and causes a fatal error. Removal rather than changing the method is likely good, as we can rely on OpenSSL using the most reasonable default. --- lib/Crypt/LE.pm | 22 +++++++++++- script/le.pl | 92 +++++++++++++++++++++++++++++++------------------ 2 files changed, 79 insertions(+), 35 deletions(-) diff --git a/lib/Crypt/LE.pm b/lib/Crypt/LE.pm index b395360..4556a4b 100644 --- a/lib/Crypt/LE.pm +++ b/lib/Crypt/LE.pm @@ -671,7 +671,6 @@ sub _set_key { my $pem = $key->get_private_key_string; my ($n, $e) = $key->get_key_parameters; return $self->_status(INVALID_DATA, "Key modulus is divisible by a small prime and will be rejected.") if $self->_is_divisible($n); - $key->use_pkcs1_padding; $key->use_sha256_hash; $self->{key_params} = { n => $n, e => $e }; $self->{key} = $key; @@ -2106,6 +2105,27 @@ sub _convert { return (!$content or $content=~/^\-+BEGIN/) ? $content : $self->der2pem($content, $type); } +sub _save_state { + my $self = shift; + return { + domains => $self->{domains}, + challenges => $self->{challenges}, + active_challenges => $self->{active_challenges}, + loaded_domains => $self->{loaded_domains}, + fingerprint => $self->{fingerprint}, + finalize => $self->{finalize}, + }; +} + +sub _load_state { + my $self = shift; + my %attributes = %{(shift)}; + foreach (keys %attributes) { + $self->{$_} = $attributes{$_}; + } + return; +} + 1; =head1 AUTHOR diff --git a/script/le.pl b/script/le.pl index 42f02ce..9505f90 100755 --- a/script/le.pl +++ b/script/le.pl @@ -12,6 +12,7 @@ use MIME::Base64 'encode_base64url'; use Crypt::LE ':errors', ':keys'; use utf8; +use Storable; my $VERSION = '0.40'; @@ -125,11 +126,11 @@ sub work { } else { $opt->{'logger'}->info("Generating a new CSR for domains $opt->{'domains'}"); if (-e $opt->{'csr-key'}) { - # Allow using pre-existing key when generating CSR - return $opt->{'error'}->("Could not load existing CSR key from $opt->{'csr-key'} - " . $le->error_details, 'CSR_KEY_LOAD') if $le->load_csr_key($opt->{'csr-key'}); - $opt->{'logger'}->info("New CSR will be based on '$opt->{'csr-key'}' key"); + # Allow using pre-existing key when generating CSR + return $opt->{'error'}->("Could not load existing CSR key from $opt->{'csr-key'} - " . $le->error_details, 'CSR_KEY_LOAD') if $le->load_csr_key($opt->{'csr-key'}); + $opt->{'logger'}->info("New CSR will be based on '$opt->{'csr-key'}' key"); } else { - $opt->{'logger'}->info("New CSR will be based on a generated key"); + $opt->{'logger'}->info("New CSR will be based on a generated key"); } my ($type, $attr) = $opt->{'curve'} ? (KEY_ECC, $opt->{'curve'}) : (KEY_RSA, $opt->{'legacy'} ? 2048 : 4096); $le->generate_csr($opt->{'domains'}, $type, $attr) == OK or return $opt->{'error'}->("Could not generate a CSR: " . $le->error_details, 'CSR_GENERATE'); @@ -159,12 +160,12 @@ sub work { my %seen; # Check wildcards last, try www for those unless already seen. foreach my $e (sort { $b cmp $a } @{$le->domains}) { - my $domain = $e=~/^\*\.(.+)$/ ? "www.$1" : $e; - next if $seen{$domain}++; - $opt->{'logger'}->info("Checking $domain"); - $opt->{'expires'} = $le->check_expiration("https://$domain/"); - last if (defined $opt->{'expires'}); - } + my $domain = $e=~/^\*\.(.+)$/ ? "www.$1" : $e; + next if $seen{$domain}++; + $opt->{'logger'}->info("Checking $domain"); + $opt->{'expires'} = $le->check_expiration("https://$domain/"); + last if (defined $opt->{'expires'}); + } } } return $opt->{'error'}->("Could not get the certificate expiration value, cannot renew.", 'EXPIRATION_GET') unless (defined $opt->{'expires'}); @@ -176,7 +177,7 @@ sub work { } $opt->{'logger'}->info("Expiration threshold set at $opt->{'renew'} days, the certificate " . ($opt->{'expires'} < 0 ? "has already expired" : "expires in $opt->{'expires'} days") . " - will be renewing."); } - + if ($opt->{'email'}) { return $opt->{'error'}->($le->error_details, 'EMAIL_SET') if $le->set_account_email($opt->{'email'}); } @@ -185,33 +186,51 @@ sub work { my $reg = _register($le, $opt); return $reg if $reg; - # Build a copy of the parameters from the command line and added during the runtime, reduced to plain vars and hashrefs. - my %callback_data = map { $_ => $opt->{$_} } grep { ! ref $opt->{$_} or ref $opt->{$_} eq 'HASH' } keys %{$opt}; - # We might not need to re-verify, verification holds for a while. NB: Only do that for the standard LE servers. my $new_crt_status = ($opt->{'server'} or $opt->{'directory'}) ? AUTH_ERROR : $le->request_certificate(); - unless ($new_crt_status) { - $opt->{'logger'}->info("Received domain certificate, no validation required at this time."); - } else { - # If it's not an auth problem, but blacklisted domains for example - stop. - return $opt->{'error'}->("Error requesting certificate: " . $le->error_details, 'CERTIFICATE_GET') if $new_crt_status != AUTH_ERROR; - # Handle DNS internally along with HTTP - my ($challenge_handler, $verification_handler) = ($opt->{'handler'}, $opt->{'handler'}); - if (!$opt->{'handler'}) { - if ($opt->{'handle-as'}) { - return $opt->{'error'}->("Only 'http' and 'dns' can be handled internally, use external modules for other verification types.", 'VERIFICATION_METHOD') unless $opt->{'handle-as'}=~/^(http|dns)$/i; - if (lc($1) eq 'dns') { - ($challenge_handler, $verification_handler) = (\&process_challenge_dns, \&process_verification_dns); - } + + my %callback_data; + + # Handle DNS internally along with HTTP + my ($challenge_handler, $verification_handler) = ($opt->{'handler'}, $opt->{'handler'}); + if (!$opt->{'handler'}) { + if ($opt->{'handle-as'}) { + return $opt->{'error'}->("Only 'http' and 'dns' can be handled internally, use external modules for other verification types.", 'VERIFICATION_METHOD') unless $opt->{'handle-as'}=~/^(http|dns)$/i; + if (lc($1) eq 'dns') { + ($challenge_handler, $verification_handler) = (\&process_challenge_dns, \&process_verification_dns); } } + } - return $opt->{'error'}->($le->error_details, 'CHALLENGE_REQUEST') if $le->request_challenge(); - return $opt->{'error'}->($le->error_details, 'CHALLENGE_ACCEPT') if $le->accept_challenge($challenge_handler || \&process_challenge, \%callback_data, $opt->{'handle-as'}); + unless ($opt->{'resume'}) { + # Build a copy of the parameters from the command line and added during the runtime, reduced to plain vars and hashrefs. + %callback_data = map { $_ => $opt->{$_} } grep { ! ref $opt->{$_} or ref $opt->{$_} eq 'HASH' } keys %{$opt}; - # If delayed mode is requested, exit early with the same code as for the issuance. - return { code => $opt->{'issue-code'}||0 } if $opt->{'delayed'}; + unless ($new_crt_status) { + $opt->{'logger'}->info("Received domain certificate, no validation required at this time."); + } else { + # If it's not an auth problem, but blacklisted domains for example - stop. + return $opt->{'error'}->("Error requesting certificate: " . $le->error_details, 'CERTIFICATE_GET') if $new_crt_status != AUTH_ERROR; + mkdir('./challenges'); + return $opt->{'error'}->($le->error_details, 'CHALLENGE_REQUEST') if $le->request_challenge(); + return $opt->{'error'}->($le->error_details, 'CHALLENGE_ACCEPT') if $le->accept_challenge($challenge_handler || \&process_challenge, \%callback_data, $opt->{'handle-as'}); + + # If delayed mode is requested, exit early with the same code as for the issuance. + if ($opt->{'delayed'}) { + store(\%callback_data, 'store_callback_data'); + store($le->_save_state(), 'store_le'); + return { code => $opt->{'issue-code'} || 0 }; + } + } + } else { + %callback_data = %{retrieve('store_callback_data')}; + my $state = retrieve('store_le'); + $le->_load_state($state); + unlink 'store_callback_data'; + unlink 'store_le'; + } + if ($new_crt_status || $opt->{'resume'}) { # Refresh nonce in case of a long delay between the challenge and the verification step. return $opt->{'error'}->($le->error_details, 'NONCE_REFRESH') unless $le->new_nonce(); return $opt->{'error'}->($le->error_details, 'CHALLENGE_VERIFY') if $le->verify_challenge($verification_handler || \&process_verification, \%callback_data, $opt->{'handle-as'}); @@ -294,7 +313,7 @@ sub parse_options { GetOptions ($opt, 'key=s', 'csr=s', 'csr-key=s', 'domains=s', 'path=s', 'crt=s', 'email=s', 'curve=s', 'server=s', 'directory=s', 'api=i', 'config=s', 'renew=i', 'renew-check=s','issue-code=i', 'handle-with=s', 'handle-as=s', 'handle-params=s', 'complete-with=s', 'complete-params=s', 'log-config=s', 'update-contacts=s', 'export-pfx=s', 'tag-pfx=s', - 'eab-kid=s', 'eab-hmac-key=s', 'ca=s', 'alternative=i', 'generate-missing', 'generate-only', 'delay=i', 'max-server-delay=i', 'revoke', 'revoke-reason=s', 'legacy', 'unlink', 'delayed', 'live', 'quiet', 'debug+', 'help') || + 'eab-kid=s', 'eab-hmac-key=s', 'ca=s', 'alternative=i', 'generate-missing', 'generate-only', 'delay=i', 'max-server-delay=i', 'revoke', 'revoke-reason=s', 'legacy', 'unlink', 'delayed', 'resume', 'live', 'quiet', 'debug+', 'help') || return $opt->{'error'}->("Use --help to see the usage examples.", 'PARAMETERS_PARSE'); if ($opt->{'config'}) { @@ -678,7 +697,11 @@ sub process_challenge_dns { unless ($params->{'delayed'}) { print "Wait for DNS to update by checking it with the command: nslookup -q=TXT _acme-challenge.$host\nWhen you see a text record returned, press \n"; ; - } + } else { + my $filename = "$challenge->{domain}.".time; + $filename =~ s/\*/wildcard/; + _write("./challenges/$filename", "_acme-challenge.$host\n$value"); + } return 1; } @@ -886,7 +909,7 @@ sub usage_and_exit { -update-contacts : Update contact details. -export-pfx : Export PFX (Windows binaries only). -tag-pfx : Tag PFX with a specific name. --alternative : Save an alternative ceritifcate (if available). +-alternative : Save an alternative certificate (if available). -config : Configuration file for the client. -log-config : Configuration file for logging. -generate-missing : Generate missing files (key, csr and csr-key). @@ -898,6 +921,7 @@ sub usage_and_exit { -max-server-delay : Cap server-specified delay (which could be unreasonably long). -legacy : Legacy mode (shorter keys, separate CA file). -delayed : Exit after requesting the challenge. +-resume : Pick-up after running delayed and completing challenge(s). -live : Use the live server instead of the test one. -debug : Print out debug messages. -quiet : Suppress all messages but errors. From f08994eb4213631a590e0dce30ae272d40483e37 Mon Sep 17 00:00:00 2001 From: Alexander Lindberg Date: Thu, 3 Jul 2025 11:01:18 +0300 Subject: [PATCH 2/2] Indentation and cosmetic cleanup --- script/le.pl | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/script/le.pl b/script/le.pl index 9505f90..7e9dfa4 100755 --- a/script/le.pl +++ b/script/le.pl @@ -160,12 +160,12 @@ sub work { my %seen; # Check wildcards last, try www for those unless already seen. foreach my $e (sort { $b cmp $a } @{$le->domains}) { - my $domain = $e=~/^\*\.(.+)$/ ? "www.$1" : $e; - next if $seen{$domain}++; - $opt->{'logger'}->info("Checking $domain"); - $opt->{'expires'} = $le->check_expiration("https://$domain/"); - last if (defined $opt->{'expires'}); - } + my $domain = $e=~/^\*\.(.+)$/ ? "www.$1" : $e; + next if $seen{$domain}++; + $opt->{'logger'}->info("Checking $domain"); + $opt->{'expires'} = $le->check_expiration("https://$domain/"); + last if (defined $opt->{'expires'}); + } } } return $opt->{'error'}->("Could not get the certificate expiration value, cannot renew.", 'EXPIRATION_GET') unless (defined $opt->{'expires'}); @@ -177,7 +177,6 @@ sub work { } $opt->{'logger'}->info("Expiration threshold set at $opt->{'renew'} days, the certificate " . ($opt->{'expires'} < 0 ? "has already expired" : "expires in $opt->{'expires'} days") . " - will be renewing."); } - if ($opt->{'email'}) { return $opt->{'error'}->($le->error_details, 'EMAIL_SET') if $le->set_account_email($opt->{'email'}); } @@ -698,10 +697,10 @@ sub process_challenge_dns { print "Wait for DNS to update by checking it with the command: nslookup -q=TXT _acme-challenge.$host\nWhen you see a text record returned, press \n"; ; } else { - my $filename = "$challenge->{domain}.".time; - $filename =~ s/\*/wildcard/; - _write("./challenges/$filename", "_acme-challenge.$host\n$value"); - } + my $filename = "$challenge->{domain}.".time; + $filename =~ s/\*/wildcard/; + _write("./challenges/$filename", "_acme-challenge.$host\n$value"); + } return 1; }