From 34d15dc7a56444a2fe8caabb34873e8126c97951 Mon Sep 17 00:00:00 2001 From: Toddr Bot Date: Sun, 12 Apr 2026 09:21:30 +0000 Subject: [PATCH] fix: sync error and expires fields in Order/Authorization update() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Order::update() only synced status and certificate, silently dropping the error field that ACME servers send when an order goes invalid (RFC 8555 ยง7.1.3). Users polling an invalid order had no way to find out why it failed. Also sync expires in both Order and Authorization update(), since servers may update expiry times between polls. Co-Authored-By: Claude Opus 4.6 --- MANIFEST | 1 + lib/Net/ACME2/Authorization.pm | 2 +- lib/Net/ACME2/Order.pm | 9 +- t/Net-ACME2-update-fields.t | 172 +++++++++++++++++++++++++++++++++ t/lib/Test/ACME2_Server.pm | 21 +++- 5 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 t/Net-ACME2-update-fields.t diff --git a/MANIFEST b/MANIFEST index e77ce8f..0854c5c 100644 --- a/MANIFEST +++ b/MANIFEST @@ -70,5 +70,6 @@ t/Net-ACME2-order-lifecycle.t t/Net-ACME2-PromiseUtil.t t/Net-ACME2-RetryAfter.t t/Net-ACME2-revoke.t +t/Net-ACME2-update-fields.t t/Net-ACME2.t t/Net-ACME2_pre_rename.t diff --git a/lib/Net/ACME2/Authorization.pm b/lib/Net/ACME2/Authorization.pm index 87aa057..dc527b9 100644 --- a/lib/Net/ACME2/Authorization.pm +++ b/lib/Net/ACME2/Authorization.pm @@ -144,7 +144,7 @@ sub challenges { sub update { my ($self, $new_hr) = @_; - for my $name ( 'status', 'challenges' ) { + for my $name ( 'status', 'challenges', 'expires' ) { $self->{"_$name"} = $new_hr->{$name}; } diff --git a/lib/Net/ACME2/Order.pm b/lib/Net/ACME2/Order.pm index 2a2042e..13d5aa6 100644 --- a/lib/Net/ACME2/Order.pm +++ b/lib/Net/ACME2/Order.pm @@ -29,6 +29,7 @@ use constant _ACCESSORS => ( 'certificate', 'finalize', 'retry_after', + 'error', ); =head1 ACCESSORS @@ -57,6 +58,12 @@ The C value from the most recent poll response, or C if the server did not send one. Only populated after C. +=item * B + +The error object (as a hash reference) from the ACME server when the +order's status is C. This is an RFC 7807 problem document. +C when the order has no error. Updated by C. + =back =head2 I->retry_after_seconds() @@ -110,7 +117,7 @@ sub identifiers { sub update { my ($self, $new_hr) = @_; - for my $name ( 'status', 'certificate' ) { + for my $name ( 'status', 'certificate', 'expires', 'error' ) { $self->{"_$name"} = $new_hr->{$name}; } diff --git a/t/Net-ACME2-update-fields.t b/t/Net-ACME2-update-fields.t new file mode 100644 index 0000000..0fc3546 --- /dev/null +++ b/t/Net-ACME2-update-fields.t @@ -0,0 +1,172 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use Test::More; +use Test::Deep; +use Test::FailWarnings; + +use Digest::MD5; +use HTTP::Status; +use URI; +use JSON; + +use Crypt::Format (); +use MIME::Base64 (); + +use FindBin; +use lib "$FindBin::Bin/lib"; +use Test::ACME2_Server; + +#---------------------------------------------------------------------- + +{ + package MyCA; + + use parent qw( Net::ACME2 ); + + use constant { + HOST => 'acme.someca.net', + DIRECTORY_PATH => '/acme-directory', + }; +} + +my $_P256_KEY = < sub { + my $SERVER_OBJ = Test::ACME2_Server->new( + ca_class => 'MyCA', + ); + + my $acme = MyCA->new( key => $_P256_KEY ); + $acme->create_account( termsOfServiceAgreed => 1 ); + + my $order = $acme->create_order( + identifiers => [ + { type => 'dns', value => 'example.com' }, + ], + ); + + is( $order->error(), undef, 'no error on new order' ); + + # Simulate the server marking the order invalid with an error + $SERVER_OBJ->{'_order_invalid'} = 1; + $SERVER_OBJ->{'_order_error'} = { + type => 'urn:ietf:params:acme:error:unauthorized', + detail => 'CAA record forbids issuance', + status => 403, + }; + + my $status = $acme->poll_order($order); + is( $status, 'invalid', 'poll_order returns invalid' ); + is( $order->status(), 'invalid', 'order status updated' ); + + my $error = $order->error(); + ok( $error, 'error field is populated' ); + is( ref $error, 'HASH', 'error is a hashref' ); + is( $error->{'type'}, 'urn:ietf:params:acme:error:unauthorized', 'error type' ); + is( $error->{'detail'}, 'CAA record forbids issuance', 'error detail' ); + is( $error->{'status'}, 403, 'error status' ); +}; + +subtest 'Order error cleared when order becomes valid' => sub { + my $SERVER_OBJ = Test::ACME2_Server->new( + ca_class => 'MyCA', + ); + + my $acme = MyCA->new( key => $_P256_KEY ); + $acme->create_account( termsOfServiceAgreed => 1 ); + + my $order = $acme->create_order( + identifiers => [ + { type => 'dns', value => 'example.com' }, + ], + ); + + # First, mark order invalid with error + $SERVER_OBJ->{'_order_invalid'} = 1; + $SERVER_OBJ->{'_order_error'} = { + type => 'urn:ietf:params:acme:error:unauthorized', + detail => 'temporary issue', + }; + + $acme->poll_order($order); + ok( $order->error(), 'error set after invalid poll' ); + + # Now simulate recovery (server no longer reports error) + $SERVER_OBJ->{'_order_invalid'} = 0; + $SERVER_OBJ->{'_order_finalized'} = 1; + delete $SERVER_OBJ->{'_order_error'}; + + $acme->poll_order($order); + is( $order->status(), 'valid', 'order recovered to valid' ); + is( $order->error(), undef, 'error cleared after valid poll' ); +}; + +subtest 'Order expires updated on poll' => sub { + my $SERVER_OBJ = Test::ACME2_Server->new( + ca_class => 'MyCA', + ); + + my $acme = MyCA->new( key => $_P256_KEY ); + $acme->create_account( termsOfServiceAgreed => 1 ); + + my $order = $acme->create_order( + identifiers => [ + { type => 'dns', value => 'example.com' }, + ], + ); + + # Initially no expires (our mock doesn't set one by default) + my $initial_expires = $order->expires(); + + # Set expires on the server + $SERVER_OBJ->{'_order_expires'} = '2099-12-31T23:59:59Z'; + + $acme->poll_order($order); + is( $order->expires(), '2099-12-31T23:59:59Z', 'expires updated from poll response' ); + + # Change expires again + $SERVER_OBJ->{'_order_expires'} = '2100-06-15T12:00:00Z'; + + $acme->poll_order($order); + is( $order->expires(), '2100-06-15T12:00:00Z', 'expires updated again on subsequent poll' ); +}; + +subtest 'Authorization expires updated on poll' => sub { + my $SERVER_OBJ = Test::ACME2_Server->new( + ca_class => 'MyCA', + ); + + my $acme = MyCA->new( key => $_P256_KEY ); + $acme->create_account( termsOfServiceAgreed => 1 ); + + my $order = $acme->create_order( + identifiers => [ + { type => 'dns', value => 'example.com' }, + ], + ); + + my @authz_urls = $order->authorizations(); + my $authz = $acme->get_authorization( $authz_urls[0] ); + + # Initially no expires + my $initial_expires = $authz->expires(); + + # Set expires on the server + $SERVER_OBJ->{'_authz_expires'} = '2099-12-31T23:59:59Z'; + + $acme->poll_authorization($authz); + is( $authz->expires(), '2099-12-31T23:59:59Z', 'authz expires updated from poll response' ); +}; + +done_testing(); diff --git a/t/lib/Test/ACME2_Server.pm b/t/lib/Test/ACME2_Server.pm index 1b3b72b..aeb50d3 100644 --- a/t/lib/Test/ACME2_Server.pm +++ b/t/lib/Test/ACME2_Server.pm @@ -324,7 +324,9 @@ sub new { 'POST:/order/1' => sub { my $h = $self->{'ca_class'}->HOST(); - my $status = $self->{'_order_finalized'} ? 'valid' : 'pending'; + my $status = $self->{'_order_invalid'} ? 'invalid' + : $self->{'_order_finalized'} ? 'valid' + : 'pending'; my $order = $self->{'_orders'}{1}; $order->{'status'} = $status; @@ -333,6 +335,17 @@ sub new { $order->{'certificate'} = "https://$h/cert/1"; } + if ($self->{'_order_error'}) { + $order->{'error'} = $self->{'_order_error'}; + } + else { + delete $order->{'error'}; + } + + if ($self->{'_order_expires'}) { + $order->{'expires'} = $self->{'_order_expires'}; + } + my %extra_headers; if ($self->{'_retry_after_order'}) { $extra_headers{'retry-after'} = $self->{'_retry_after_order'}; @@ -477,9 +490,15 @@ sub _authz_content { : $self->{'_challenge_accepted'} ? 'valid' : 'pending'; + my %extra; + if ($self->{'_authz_expires'}) { + $extra{'expires'} = $self->{'_authz_expires'}; + } + return { status => $status, identifier => { type => 'dns', value => 'example.com' }, + %extra, challenges => [ { type => 'http-01',