10 use MIME
::Base64
qw(encode_base64url);
11 use File
::Path
qw(make_path);
13 use Digest
::SHA
qw(sha256 sha256_hex);
18 use Crypt
::OpenSSL
::RSA
;
26 use PVE
::ACME
::DNSChallenge
;
28 Crypt
::OpenSSL
::RSA-
>import_random_seed();
30 my $LETSENCRYPT_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory';
32 ### ACME library (compatible with Let's Encrypt v2 API)
36 # 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL');
37 # 2) $acme->init(4096); # generate account key
38 # 4) my $tos_url = $acme->get_meta()->{termsOfService}; # optional, display if applicable
39 # 5) $acme->new_account($tos_url, contact => ['mailto:example@example.com']);
41 # 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL');
43 # 3) my ($order_url, $order) = $acme->new_order(['foo.example.com', 'bar.example.com']);
44 # 4) # repeat a-f for each $auth_url in $order->{authorizations}
45 # a) my $authorization = $acme->get_authorization($auth_url);
46 # b) # pick $challenge from $authorization->{challenges} according to desired type
47 # c) my $key_auth = $acme->key_authorization($challenge->{token});
48 # d) # setup challenge validation according to specification
49 # e) $acme->request_challenge_validation($challenge->{url});
50 # f) # poll $acme->get_authorization($auth_url) until status is 'valid'
51 # 5) # generate CSR in PEM format
52 # 6) $acme->finalize_order($order, $csr);
53 # 7) # poll $acme->get_order($order_url) until status is 'valid'
54 # 8) my $cert = $acme->get_certificate($order);
55 # 9) # $key is path to key file, $cert contains PEM-encoded certificate chain
57 # 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL');
59 # 3) $acme->revoke_certificate($cert);
62 sub encode
($) { # acme requires 'base64url' encoding
63 return encode_base64url
($_[0]);
66 sub tojs
($;%) { # shortcut for to_json with utf8=>1
67 my ($data, %data) = @_;
68 return to_json
($data, { utf8
=> 1, %data });
72 return from_json
($_[0]);
76 my ($self, $msg, $dump, $noerr) = @_;
78 warn Dumper
($dump), "\n" if $self->{debug
} && $dump;
88 # $path: account JSON file
89 # $directory: the ACME directory URL used to find method URLs
91 my ($class, $path, $directory) = @_;
93 $directory //= $LETSENCRYPT_STAGING;
95 my $ua = LWP
::UserAgent-
>new();
97 $ua->agent('pve-acme/0.1');
98 $ua->protocols_allowed(['https']);
103 directory
=> $directory,
111 return bless $self, $class;
114 # RS256: PKCS#1 padding, no OAEP, SHA256
115 my $configure_key = sub {
117 $key->use_pkcs1_padding();
118 $key->use_sha256_hash();
121 # Create account key with $keybits bits
122 # use instead of load, overwrites existing account JSON file!
124 my ($self, $keybits) = @_;
125 die "Already have a key\n" if defined($self->{key
});
127 my $key = Crypt
::OpenSSL
::RSA-
>generate_key($keybits);
128 $configure_key->($key);
133 my @SAVED_VALUES = qw(location account tos debug directory);
134 # Serialize persistent parts of $self to $self->{path} as JSON
139 if (my $key = $self->{key
}) {
140 $keystr = $key->get_private_key_string();
143 for my $k (@SAVED_VALUES) {
144 my $v = $self->{$k} // next;
147 # pretty => 1 for readability
148 # canonical => 1 to reduce churn
149 file_set_contents
($self->{path
}, tojs
($o, pretty
=> 1, canonical
=> 1));
152 # Load serialized account JSON file into $self
155 return if $self->{loaded
};
157 my $raw = file_get_contents
($self->{path
});
158 if ($raw =~ m/^(.*)$/s) { $raw = $1; } # untaint
159 my $data = fromjs
($raw);
160 $self->{$_} = $data->{$_} for @SAVED_VALUES;
161 if (defined(my $keystr = $data->{key
})) {
162 my $key = Crypt
::OpenSSL
::RSA-
>new_private_key($keystr);
163 $configure_key->($key);
168 # The 'jwk' object needs the key type, key parameters and the usage,
169 # except for when we want to take the JWK-Thumbprint, then the usage
170 # must not be included.
172 my ($self, $pure) = @_;
173 my $key = $self->{key
}
174 or die "No key was generated yet\n";
175 my ($n, $e) = $key->get_key_parameters();
178 ($pure ?
() : (use => 'sig')), # for thumbprints
179 n
=> encode
($n->to_bin),
180 e
=> encode
($e->to_bin),
184 # The thumbprint is a sha256 hash of the lexicographically sorted (iow.
185 # canonical) condensed json string of the JWK object which gets base64url
189 my $jwk = $self->jwk(1); # $pure = 1
190 return encode
(sha256
(tojs
($jwk, canonical
=>1))); # canonical sorts
193 # A key authorization string in acme is a challenge token dot-connected with
194 # a JWK Thumbprint. You put the base64url encoded sha256-hash of this string
195 # into the DNS TXT record.
196 sub key_authorization
{
197 my ($self, $token) = @_;
198 return $token .'.'. $self->jwk_thumbprint();
201 # JWS signing using the RS256 alg (RSA/SHA256).
203 my ($self, $use_jwk, $data, $url) = @_;
204 my $key = $self->{key
}
205 or die "No key was generated yet\n";
207 my $payload = $data ne '' ? encode
(tojs
($data)) : $data;
209 if (!defined($self->{nonce
})) {
210 my $method = $self->_method('newNonce');
211 $self->do(GET
=> $method);
214 # The acme protocol requires the actual request URL be in the protected
215 # header. There is no unprotected header.
219 nonce
=> $self->{nonce
} // die "missing nonce\n"
222 # header contains either
223 # - kid, reference to account URL
225 # the latter is only allowed for
226 # - creating accounts (no account URL yet)
227 # - revoking certificates with the certificate key instead of account key
229 $protected->{jwk
} = $self->jwk();
231 $protected->{kid
} = $self->{location
};
234 $protected = encode
(tojs
($protected));
236 my $signdata = "$protected.$payload";
237 my $signature = encode
($key->sign($signdata));
240 protected
=> $protected,
242 signature
=> $signature,
247 my ($resp, $code, $plain) = @_;
249 die "expected code '$code', received '".$resp->code."'\n"
250 if $resp->code != $code;
252 return $plain ?
$resp->decoded_content : fromjs
($resp->decoded_content);
255 # Get the list of method URLs and query the directory if we have to.
258 if (my $methods = $self->{methods
}) {
261 my $r = $self->do(GET
=> $self->{directory
});
262 my $methods = __get_result
($r, 200);
263 $self->fatal("unable to decode methods returned by directory - $@", $r) if $@;
264 return ($self->{methods
} = $methods);
267 # Get a method, causing the directory to be queried first if necessary.
269 my ($self, $method) = @_;
270 my $methods = $self->__get_methods();
271 my $url = $methods->{$method}
272 or die "no such method: $method\n";
276 # Get $self->{account} with an error if we don't have one yet.
279 my $account = $self->{account
}
280 // die "no account loaded\n";
281 return wantarray ?
($account, $self->{location
}) : $account;
287 my $methods = $self->__get_methods();
288 if (my $meta = $methods->{meta
}) {
289 print("(meta): $_ : $meta->{$_}\n") for sort keys %$meta;
291 print("$_ : $methods->{$_}\n") for sort grep {$_ ne 'meta'} keys %$methods;
294 # return (optional) meta directory entry.
295 # this is public because it might contain the ToS, which should be displayed
296 # and agreed to before creating an account
299 my $methods = $self->__get_methods();
300 return $methods->{meta
};
303 # Common code between new_account and update_account
305 my ($self, $expected_code, $url, $new, %info) = @_;
309 my $r = $self->do(POST
=> $url, $req, $new);
311 my $account = __get_result
($r, $expected_code);
312 if (!defined($self->{location
})) {
313 my $account_url = $r->header('Location')
314 or die "did not receive an account URL\n";
315 $self->{location
} = $account_url;
317 $self->{account
} = $account;
320 $self->fatal("POST to '$url' failed - $@", $r) if $@;
321 return $self->{account
};
324 # Create a new account using data in %info.
325 # Optionally pass $tos_url to agree to the given Terms of Service
326 # POST to newAccount endpoint
327 # Expects a '201 Created' reply
328 # Saves and returns the account data
330 my ($self, $tos_url, %info) = @_;
331 my $url = $self->_method('newAccount');
334 $self->{tos
} = $tos_url;
335 $info{termsOfServiceAgreed
} = JSON
::true
;
338 return $self->__new_account(201, $url, 1, %info);
341 # Update existing account with new %info
342 # POST to account URL
343 # Expects a '200 OK' reply
344 # Saves and returns updated account data
346 my ($self, %info) = @_;
347 my (undef, $url) = $self->_account;
349 return $self->__new_account(200, $url, 0, %info);
352 # Retrieves existing account information
353 # POST to account URL with empty body!
354 # Expects a '200 OK' reply
355 # Saves and returns updated account data
358 return $self->update_account();
361 # Start a new order for one or more domains
362 # POST to newOrder endpoint
363 # Expects a '201 Created' reply
364 # returns order URL and parsed order object, including authorization and finalize URLs
366 my ($self, $domains) = @_;
368 my $url = $self->_method('newOrder');
370 identifiers
=> [ map { { type
=> 'dns', value
=> $_ } } @$domains ],
373 my $r = $self->do(POST
=> $url, $req);
374 my ($order_url, $order);
376 $order_url = $r->header('Location')
377 or die "did not receive an order URL\n";
378 $order = __get_result
($r, 201)
380 $self->fatal("POST to '$url' failed - $@", $r) if $@;
381 return ($order_url, $order);
384 # Finalize order after all challenges have been validated
385 # POST to order's finalize URL
386 # Expects a '200 OK' reply
387 # returns (potentially updated) order object
389 my ($self, $order, $csr) = @_;
394 my $r = $self->do(POST
=> $order->{finalize
}, $req);
395 my $return = eval { __get_result
($r, 200); };
396 $self->fatal("POST to '$order->{finalize}' failed - $@", $r) if $@;
401 # GET-as-POST to order URL
402 # Expects a '200 OK' reply
403 # returns order object
405 my ($self, $order_url) = @_;
406 my $r = $self->do(POST
=> $order_url, '');
407 my $return = eval { __get_result
($r, 200); };
408 $self->fatal("POST of '$order_url' failed - $@", $r) if $@;
412 # Gets authorization object
413 # GET-as-POST to authorization URL
414 # Expects a '200 OK' reply
415 # returns authorization object, including challenges array
416 sub get_authorization
{
417 my ($self, $auth_url) = @_;
419 my $r = $self->do(POST
=> $auth_url, '');
420 my $return = eval { __get_result
($r, 200); };
421 $self->fatal("POST of '$auth_url' failed - $@", $r) if $@;
425 # Deactivates existing authorization
426 # POST to authorization URL
427 # Expects a '200 OK' reply
428 # returns updated authorization object
429 sub deactivate_authorization
{
430 my ($self, $auth_url) = @_;
433 status
=> 'deactivated',
435 my $r = $self->do(POST
=> $auth_url, $req);
436 my $return = eval { __get_result
($r, 200); };
437 $self->fatal("POST to '$auth_url' failed - $@", $r) if $@;
442 # GET-as-POST to order's certificate URL
443 # Expects a '200 OK' reply
444 # returns certificate chain in PEM format
445 sub get_certificate
{
446 my ($self, $order) = @_;
448 $self->fatal("no certificate URL available (yet?)", $order)
449 if !$order->{certificate
};
451 my $r = $self->do(POST
=> $order->{certificate
}, '');
452 my $return = eval { __get_result
($r, 200, 1); };
453 $self->fatal("POST of '$order->{certificate}' failed - $@", $r) if $@;
457 # Revoke given certificate
458 # POST to revokeCert endpoint
459 # currently only supports revokation with account key
460 # $certificate can either be PEM or DER encoded
461 # Expects a '200 OK' reply
462 sub revoke_certificate
{
463 my ($self, $certificate, $reason) = @_;
465 my $url = $self->_method('revokeCert');
467 if ($certificate =~ /^-----BEGIN CERTIFICATE-----/) {
468 $certificate = PVE
::Certificate
::pem_to_der
($certificate);
472 certificate
=> encode
($certificate),
473 reason
=> $reason // 0,
475 # TODO: set use_jwk if revoking with certificate key
476 my $r = $self->do(POST
=> $url, $req);
478 die "unexpected code $r->code\n" if $r->code != 200;
480 $self->fatal("POST to '$url' failed - $@", $r) if $@;
483 # Request validation of challenge
484 # POST to challenge URL
485 # call after validation has been setup
486 # returns (potentially updated) challenge object
487 sub request_challenge_validation
{
488 my ($self, $url) = @_;
490 my $r = $self->do(POST
=> $url, {});
491 my $return = eval { __get_result
($r, 200); };
492 $self->fatal("POST to '$url' failed - $@", $r) if $@;
496 # actually 'do' a $method request on $url
497 # $data: input for JWS, optional
498 # $use_jwk: use JWK instead of KID in JWD (see sub jws)
500 my ($self, $method, $url, $data, $use_jwk) = @_;
502 $self->fatal("Error: can't $method to empty URL") if !$url || $url eq '';
504 my $headers = HTTP
::Headers-
>new();
505 $headers->header('Content-Type' => 'application/jose+json');
506 my $content = defined($data) ?
$self->jws($use_jwk, $data, $url) : undef;
508 if (defined($content)) {
509 $content = tojs
($content);
510 $request = HTTP
::Request-
>new($method, $url, $headers, $content);
512 $request = HTTP
::Request-
>new($method, $url, $headers);
514 my $res = $self->{ua
}->request($request);
515 if (!$res->is_success) {
516 # check for nonce rejection
517 if ($res->code == 400 && $res->decoded_content) {
518 my $parsed_content = fromjs
($res->decoded_content);
519 if ($parsed_content->{type
} eq 'urn:ietf:params:acme:error:badNonce') {
520 warn("bad Nonce, retrying\n");
521 $self->{nonce
} = $res->header('Replay-Nonce');
522 return $self->do($method, $url, $data, $use_jwk);
525 $self->fatal("Error: $method to $url\n".$res->decoded_content, $res);
527 if (my $nonce = $res->header('Replay-Nonce')) {
528 $self->{nonce
} = $nonce;