]>
Commit | Line | Data |
---|---|---|
5460050d WL |
1 | package PVE::ACME; |
2 | ||
3 | use strict; | |
4 | use warnings; | |
5 | ||
6 | use POSIX; | |
7 | ||
8 | use Data::Dumper; | |
9 | use Date::Parse; | |
10 | use MIME::Base64 qw(encode_base64url); | |
11 | use File::Path qw(make_path); | |
12 | use JSON; | |
13 | use Digest::SHA qw(sha256 sha256_hex); | |
14 | ||
15 | use HTTP::Request; | |
16 | use LWP::UserAgent; | |
17 | ||
18 | use Crypt::OpenSSL::RSA; | |
19 | ||
20 | use PVE::Certificate; | |
21 | use PVE::Tools qw( | |
22 | file_set_contents | |
23 | file_get_contents | |
24 | ); | |
25 | ||
98b96d9e WL |
26 | use PVE::ACME::DNSChallenge; |
27 | ||
5460050d WL |
28 | Crypt::OpenSSL::RSA->import_random_seed(); |
29 | ||
30 | my $LETSENCRYPT_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory'; | |
31 | ||
32 | ### ACME library (compatible with Let's Encrypt v2 API) | |
33 | # | |
34 | # sample usage: | |
35 | # | |
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']); | |
40 | # | |
41 | # 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL'); | |
42 | # 2) $acme->load(); | |
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 | |
109fc3fd | 49 | # e) $acme->request_challenge_validation($challenge->{url}); |
5460050d WL |
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 | |
56 | # | |
57 | # 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL'); | |
58 | # 2) $acme->load(); | |
59 | # 3) $acme->revoke_certificate($cert); | |
60 | ||
61 | # Tools | |
62 | sub encode($) { # acme requires 'base64url' encoding | |
63 | return encode_base64url($_[0]); | |
64 | } | |
65 | ||
66 | sub tojs($;%) { # shortcut for to_json with utf8=>1 | |
67 | my ($data, %data) = @_; | |
68 | return to_json($data, { utf8 => 1, %data }); | |
69 | } | |
70 | ||
71 | sub fromjs($) { | |
72 | return from_json($_[0]); | |
73 | } | |
74 | ||
75 | sub fatal($$;$$) { | |
76 | my ($self, $msg, $dump, $noerr) = @_; | |
77 | ||
78 | warn Dumper($dump), "\n" if $self->{debug} && $dump; | |
79 | if ($noerr) { | |
80 | warn "$msg\n"; | |
81 | } else { | |
82 | die "$msg\n"; | |
83 | } | |
84 | } | |
85 | ||
86 | # Implementation | |
87 | ||
88 | # $path: account JSON file | |
89 | # $directory: the ACME directory URL used to find method URLs | |
90 | sub new($$$) { | |
91 | my ($class, $path, $directory) = @_; | |
92 | ||
93 | $directory //= $LETSENCRYPT_STAGING; | |
94 | ||
95 | my $ua = LWP::UserAgent->new(); | |
96 | $ua->env_proxy(); | |
97 | $ua->agent('pve-acme/0.1'); | |
98 | $ua->protocols_allowed(['https']); | |
99 | ||
100 | my $self = { | |
101 | ua => $ua, | |
102 | path => $path, | |
103 | directory => $directory, | |
104 | nonce => undef, | |
105 | key => undef, | |
106 | location => undef, | |
107 | account => undef, | |
108 | tos => undef, | |
109 | }; | |
110 | ||
111 | return bless $self, $class; | |
112 | } | |
113 | ||
114 | # RS256: PKCS#1 padding, no OAEP, SHA256 | |
115 | my $configure_key = sub { | |
116 | my ($key) = @_; | |
117 | $key->use_pkcs1_padding(); | |
118 | $key->use_sha256_hash(); | |
119 | }; | |
120 | ||
121 | # Create account key with $keybits bits | |
122 | # use instead of load, overwrites existing account JSON file! | |
123 | sub init { | |
124 | my ($self, $keybits) = @_; | |
125 | die "Already have a key\n" if defined($self->{key}); | |
126 | $keybits //= 4096; | |
127 | my $key = Crypt::OpenSSL::RSA->generate_key($keybits); | |
128 | $configure_key->($key); | |
129 | $self->{key} = $key; | |
130 | $self->save(); | |
131 | } | |
132 | ||
133 | my @SAVED_VALUES = qw(location account tos debug directory); | |
134 | # Serialize persistent parts of $self to $self->{path} as JSON | |
135 | sub save { | |
136 | my ($self) = @_; | |
137 | my $o = {}; | |
138 | my $keystr; | |
139 | if (my $key = $self->{key}) { | |
140 | $keystr = $key->get_private_key_string(); | |
141 | $o->{key} = $keystr; | |
142 | } | |
143 | for my $k (@SAVED_VALUES) { | |
144 | my $v = $self->{$k} // next; | |
145 | $o->{$k} = $v; | |
146 | } | |
147 | # pretty => 1 for readability | |
148 | # canonical => 1 to reduce churn | |
149 | file_set_contents($self->{path}, tojs($o, pretty => 1, canonical => 1)); | |
150 | } | |
151 | ||
152 | # Load serialized account JSON file into $self | |
153 | sub load { | |
154 | my ($self) = @_; | |
155 | return if $self->{loaded}; | |
156 | $self->{loaded} = 1; | |
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); | |
164 | $self->{key} = $key; | |
165 | } | |
166 | } | |
167 | ||
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. | |
171 | sub jwk { | |
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(); | |
176 | return { | |
177 | kty => 'RSA', | |
178 | ($pure ? () : (use => 'sig')), # for thumbprints | |
179 | n => encode($n->to_bin), | |
180 | e => encode($e->to_bin), | |
181 | }; | |
182 | } | |
183 | ||
184 | # The thumbprint is a sha256 hash of the lexicographically sorted (iow. | |
185 | # canonical) condensed json string of the JWK object which gets base64url | |
186 | # encoded. | |
187 | sub jwk_thumbprint { | |
188 | my ($self) = @_; | |
189 | my $jwk = $self->jwk(1); # $pure = 1 | |
190 | return encode(sha256(tojs($jwk, canonical=>1))); # canonical sorts | |
191 | } | |
192 | ||
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(); | |
199 | } | |
200 | ||
201 | # JWS signing using the RS256 alg (RSA/SHA256). | |
202 | sub jws { | |
203 | my ($self, $use_jwk, $data, $url) = @_; | |
204 | my $key = $self->{key} | |
205 | or die "No key was generated yet\n"; | |
206 | ||
207 | my $payload = $data ne '' ? encode(tojs($data)) : $data; | |
208 | ||
209 | if (!defined($self->{nonce})) { | |
210 | my $method = $self->_method('newNonce'); | |
211 | $self->do(GET => $method); | |
212 | } | |
213 | ||
214 | # The acme protocol requires the actual request URL be in the protected | |
215 | # header. There is no unprotected header. | |
216 | my $protected = { | |
217 | alg => 'RS256', | |
218 | url => $url, | |
219 | nonce => $self->{nonce} // die "missing nonce\n" | |
220 | }; | |
221 | ||
222 | # header contains either | |
223 | # - kid, reference to account URL | |
224 | # - jwk, key itself | |
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 | |
228 | if ($use_jwk) { | |
229 | $protected->{jwk} = $self->jwk(); | |
230 | } else { | |
231 | $protected->{kid} = $self->{location}; | |
232 | } | |
233 | ||
234 | $protected = encode(tojs($protected)); | |
235 | ||
236 | my $signdata = "$protected.$payload"; | |
237 | my $signature = encode($key->sign($signdata)); | |
238 | ||
239 | return { | |
240 | protected => $protected, | |
241 | payload => $payload, | |
242 | signature => $signature, | |
243 | }; | |
244 | } | |
245 | ||
246 | sub __get_result { | |
247 | my ($resp, $code, $plain) = @_; | |
248 | ||
249 | die "expected code '$code', received '".$resp->code."'\n" | |
250 | if $resp->code != $code; | |
251 | ||
252 | return $plain ? $resp->decoded_content : fromjs($resp->decoded_content); | |
253 | } | |
254 | ||
255 | # Get the list of method URLs and query the directory if we have to. | |
256 | sub __get_methods { | |
257 | my ($self) = @_; | |
258 | if (my $methods = $self->{methods}) { | |
259 | return $methods; | |
260 | } | |
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); | |
265 | } | |
266 | ||
267 | # Get a method, causing the directory to be queried first if necessary. | |
268 | sub _method { | |
269 | my ($self, $method) = @_; | |
270 | my $methods = $self->__get_methods(); | |
271 | my $url = $methods->{$method} | |
272 | or die "no such method: $method\n"; | |
273 | return $url; | |
274 | } | |
275 | ||
276 | # Get $self->{account} with an error if we don't have one yet. | |
277 | sub _account { | |
278 | my ($self) = @_; | |
279 | my $account = $self->{account} | |
280 | // die "no account loaded\n"; | |
281 | return wantarray ? ($account, $self->{location}) : $account; | |
282 | } | |
283 | ||
284 | # debugging info | |
285 | sub list_methods { | |
286 | my ($self) = @_; | |
287 | my $methods = $self->__get_methods(); | |
288 | if (my $meta = $methods->{meta}) { | |
289 | print("(meta): $_ : $meta->{$_}\n") for sort keys %$meta; | |
290 | } | |
291 | print("$_ : $methods->{$_}\n") for sort grep {$_ ne 'meta'} keys %$methods; | |
292 | } | |
293 | ||
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 | |
297 | sub get_meta { | |
298 | my ($self) = @_; | |
299 | my $methods = $self->__get_methods(); | |
300 | return $methods->{meta}; | |
301 | } | |
302 | ||
303 | # Common code between new_account and update_account | |
304 | sub __new_account { | |
305 | my ($self, $expected_code, $url, $new, %info) = @_; | |
306 | my $req = { | |
307 | %info, | |
308 | }; | |
309 | my $r = $self->do(POST => $url, $req, $new); | |
310 | eval { | |
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; | |
316 | } | |
317 | $self->{account} = $account; | |
318 | $self->save(); | |
319 | }; | |
320 | $self->fatal("POST to '$url' failed - $@", $r) if $@; | |
321 | return $self->{account}; | |
322 | } | |
323 | ||
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 | |
329 | sub new_account { | |
330 | my ($self, $tos_url, %info) = @_; | |
331 | my $url = $self->_method('newAccount'); | |
332 | ||
333 | if ($tos_url) { | |
334 | $self->{tos} = $tos_url; | |
335 | $info{termsOfServiceAgreed} = JSON::true; | |
336 | } | |
337 | ||
338 | return $self->__new_account(201, $url, 1, %info); | |
339 | } | |
340 | ||
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 | |
345 | sub update_account { | |
346 | my ($self, %info) = @_; | |
347 | my (undef, $url) = $self->_account; | |
348 | ||
349 | return $self->__new_account(200, $url, 0, %info); | |
350 | } | |
351 | ||
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 | |
356 | sub get_account { | |
357 | my ($self) = @_; | |
358 | return $self->update_account(); | |
359 | } | |
360 | ||
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 | |
365 | sub new_order { | |
366 | my ($self, $domains) = @_; | |
367 | ||
368 | my $url = $self->_method('newOrder'); | |
369 | my $req = { | |
370 | identifiers => [ map { { type => 'dns', value => $_ } } @$domains ], | |
371 | }; | |
372 | ||
373 | my $r = $self->do(POST => $url, $req); | |
374 | my ($order_url, $order); | |
375 | eval { | |
376 | $order_url = $r->header('Location') | |
377 | or die "did not receive an order URL\n"; | |
378 | $order = __get_result($r, 201) | |
379 | }; | |
380 | $self->fatal("POST to '$url' failed - $@", $r) if $@; | |
381 | return ($order_url, $order); | |
382 | } | |
383 | ||
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 | |
388 | sub finalize_order { | |
389 | my ($self, $order, $csr) = @_; | |
390 | ||
391 | my $req = { | |
392 | csr => encode($csr), | |
393 | }; | |
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 $@; | |
397 | return $return; | |
398 | } | |
399 | ||
400 | # Get order status | |
401 | # GET-as-POST to order URL | |
402 | # Expects a '200 OK' reply | |
403 | # returns order object | |
404 | sub get_order { | |
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 $@; | |
409 | return $return; | |
410 | } | |
411 | ||
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) = @_; | |
418 | ||
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 $@; | |
422 | return $return; | |
423 | } | |
424 | ||
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) = @_; | |
431 | ||
432 | my $req = { | |
433 | status => 'deactivated', | |
434 | }; | |
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 $@; | |
438 | return $return; | |
439 | } | |
440 | ||
441 | # Get certificate | |
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) = @_; | |
447 | ||
448 | $self->fatal("no certificate URL available (yet?)", $order) | |
449 | if !$order->{certificate}; | |
450 | ||
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 $@; | |
454 | return $return; | |
455 | } | |
456 | ||
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) = @_; | |
464 | ||
465 | my $url = $self->_method('revokeCert'); | |
466 | ||
467 | if ($certificate =~ /^-----BEGIN CERTIFICATE-----/) { | |
468 | $certificate = PVE::Certificate::pem_to_der($certificate); | |
469 | } | |
470 | ||
471 | my $req = { | |
472 | certificate => encode($certificate), | |
473 | reason => $reason // 0, | |
474 | }; | |
475 | # TODO: set use_jwk if revoking with certificate key | |
476 | my $r = $self->do(POST => $url, $req); | |
477 | eval { | |
478 | die "unexpected code $r->code\n" if $r->code != 200; | |
479 | }; | |
480 | $self->fatal("POST to '$url' failed - $@", $r) if $@; | |
481 | } | |
482 | ||
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 { | |
109fc3fd | 488 | my ($self, $url) = @_; |
5460050d | 489 | |
109fc3fd | 490 | my $r = $self->do(POST => $url, {}); |
5460050d WL |
491 | my $return = eval { __get_result($r, 200); }; |
492 | $self->fatal("POST to '$url' failed - $@", $r) if $@; | |
493 | return $return; | |
494 | } | |
495 | ||
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) | |
499 | sub do { | |
500 | my ($self, $method, $url, $data, $use_jwk) = @_; | |
501 | ||
502 | $self->fatal("Error: can't $method to empty URL") if !$url || $url eq ''; | |
503 | ||
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; | |
507 | my $request; | |
508 | if (defined($content)) { | |
509 | $content = tojs($content); | |
510 | $request = HTTP::Request->new($method, $url, $headers, $content); | |
511 | } else { | |
512 | $request = HTTP::Request->new($method, $url, $headers); | |
513 | } | |
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); | |
523 | } | |
524 | } | |
525 | $self->fatal("Error: $method to $url\n".$res->decoded_content, $res); | |
526 | } | |
527 | if (my $nonce = $res->header('Replay-Nonce')) { | |
528 | $self->{nonce} = $nonce; | |
529 | } | |
530 | return $res; | |
531 | } | |
532 | ||
533 | 1; |