]>
Commit | Line | Data |
---|---|---|
11c08f14 FG |
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 | ||
26 | Crypt::OpenSSL::RSA->import_random_seed(); | |
27 | ||
28 | my $LETSENCRYPT_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory'; | |
29 | ||
30 | ### ACME library (compatible with Let's Encrypt v2 API) | |
31 | # | |
32 | # sample usage: | |
33 | # | |
34 | # 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL'); | |
35 | # 2) $acme->init(4096); # generate account key | |
36 | # 4) my $tos_url = $acme->get_meta()->{termsOfService}; # optional, display if applicable | |
37 | # 5) $acme->new_account($tos_url, contact => ['mailto:example@example.com']); | |
38 | # | |
39 | # 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL'); | |
40 | # 2) $acme->load(); | |
41 | # 3) my ($order_url, $order) = $acme->new_order(['foo.example.com', 'bar.example.com']); | |
42 | # 4) # repeat a-f for each $auth_url in $order->{authorizations} | |
43 | # a) my $authorization = $acme->get_authorization($auth_url); | |
44 | # b) # pick $challenge from $authorization->{challenges} according to desired type | |
45 | # c) my $key_auth = $acme->key_authorization($challenge->{token}); | |
46 | # d) # setup challenge validation according to specification | |
47 | # e) $acme->request_challenge_validation($challenge->{url}, $key_auth); | |
48 | # f) # poll $acme->get_authorization($auth_url) until status is 'valid' | |
49 | # 5) # generate CSR in PEM format | |
50 | # 6) $acme->finalize_order($order, $csr); | |
51 | # 7) # poll $acme->get_order($order_url) until status is 'valid' | |
52 | # 8) my $cert = $acme->get_certificate($order); | |
53 | # 9) # $key is path to key file, $cert contains PEM-encoded certificate chain | |
54 | # | |
55 | # 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL'); | |
56 | # 2) $acme->load(); | |
57 | # 3) $acme->revoke_certificate($cert); | |
58 | ||
59 | # Tools | |
60 | sub encode($) { # acme requires 'base64url' encoding | |
61 | return encode_base64url($_[0]); | |
62 | } | |
63 | ||
64 | sub tojs($;%) { # shortcut for to_json with utf8=>1 | |
65 | my ($data, %data) = @_; | |
66 | return to_json($data, { utf8 => 1, %data }); | |
67 | } | |
68 | ||
69 | sub fromjs($) { | |
70 | return from_json($_[0]); | |
71 | } | |
72 | ||
73 | sub fatal($$;$$) { | |
74 | my ($self, $msg, $dump, $noerr) = @_; | |
75 | ||
76 | warn Dumper($dump), "\n" if $self->{debug} && $dump; | |
77 | if ($noerr) { | |
78 | warn "$msg\n"; | |
79 | } else { | |
80 | die "$msg\n"; | |
81 | } | |
82 | } | |
83 | ||
84 | # Implementation | |
85 | ||
86 | # $path: account JSON file | |
87 | # $directory: the ACME directory URL used to find method URLs | |
88 | sub new($$$) { | |
89 | my ($class, $path, $directory) = @_; | |
90 | ||
91 | $directory //= $LETSENCRYPT_STAGING; | |
92 | ||
93 | my $ua = LWP::UserAgent->new(); | |
94 | $ua->env_proxy(); | |
95 | $ua->agent('pve-acme/0.1'); | |
96 | $ua->protocols_allowed(['https']); | |
97 | ||
98 | my $self = { | |
99 | ua => $ua, | |
100 | path => $path, | |
101 | directory => $directory, | |
102 | nonce => undef, | |
103 | key => undef, | |
104 | location => undef, | |
105 | account => undef, | |
106 | tos => undef, | |
107 | }; | |
108 | ||
109 | return bless $self, $class; | |
110 | } | |
111 | ||
112 | # RS256: PKCS#1 padding, no OAEP, SHA256 | |
113 | my $configure_key = sub { | |
114 | my ($key) = @_; | |
115 | $key->use_pkcs1_padding(); | |
116 | $key->use_sha256_hash(); | |
117 | }; | |
118 | ||
119 | # Create account key with $keybits bits | |
120 | # use instead of load, overwrites existing account JSON file! | |
121 | sub init { | |
122 | my ($self, $keybits) = @_; | |
123 | die "Already have a key\n" if defined($self->{key}); | |
124 | $keybits //= 4096; | |
125 | my $key = Crypt::OpenSSL::RSA->generate_key($keybits); | |
126 | $configure_key->($key); | |
127 | $self->{key} = $key; | |
128 | $self->save(); | |
129 | } | |
130 | ||
131 | my @SAVED_VALUES = qw(location account tos debug directory); | |
132 | # Serialize persistent parts of $self to $self->{path} as JSON | |
133 | sub save { | |
134 | my ($self) = @_; | |
135 | my $o = {}; | |
136 | my $keystr; | |
137 | if (my $key = $self->{key}) { | |
138 | $keystr = $key->get_private_key_string(); | |
139 | $o->{key} = $keystr; | |
140 | } | |
141 | for my $k (@SAVED_VALUES) { | |
142 | my $v = $self->{$k} // next; | |
143 | $o->{$k} = $v; | |
144 | } | |
145 | # pretty => 1 for readability | |
146 | # canonical => 1 to reduce churn | |
147 | file_set_contents($self->{path}, tojs($o, pretty => 1, canonical => 1)); | |
148 | } | |
149 | ||
150 | # Load serialized account JSON file into $self | |
151 | sub load { | |
152 | my ($self) = @_; | |
153 | return if $self->{loaded}; | |
154 | $self->{loaded} = 1; | |
bf5e9f84 DM |
155 | my $raw = file_get_contents($self->{path}); |
156 | if ($raw =~ m/^(.*)$/s) { $raw = $1; } # untaint | |
157 | my $data = fromjs($raw); | |
11c08f14 FG |
158 | $self->{$_} = $data->{$_} for @SAVED_VALUES; |
159 | if (defined(my $keystr = $data->{key})) { | |
160 | my $key = Crypt::OpenSSL::RSA->new_private_key($keystr); | |
161 | $configure_key->($key); | |
162 | $self->{key} = $key; | |
163 | } | |
164 | } | |
165 | ||
166 | # The 'jwk' object needs the key type, key parameters and the usage, | |
167 | # except for when we want to take the JWK-Thumbprint, then the usage | |
168 | # must not be included. | |
169 | sub jwk { | |
170 | my ($self, $pure) = @_; | |
171 | my $key = $self->{key} | |
172 | or die "No key was generated yet\n"; | |
173 | my ($n, $e) = $key->get_key_parameters(); | |
174 | return { | |
175 | kty => 'RSA', | |
176 | ($pure ? () : (use => 'sig')), # for thumbprints | |
177 | n => encode($n->to_bin), | |
178 | e => encode($e->to_bin), | |
179 | }; | |
180 | } | |
181 | ||
182 | # The thumbprint is a sha256 hash of the lexicographically sorted (iow. | |
183 | # canonical) condensed json string of the JWK object which gets base64url | |
184 | # encoded. | |
185 | sub jwk_thumbprint { | |
186 | my ($self) = @_; | |
187 | my $jwk = $self->jwk(1); # $pure = 1 | |
188 | return encode(sha256(tojs($jwk, canonical=>1))); # canonical sorts | |
189 | } | |
190 | ||
191 | # A key authorization string in acme is a challenge token dot-connected with | |
192 | # a JWK Thumbprint. You put the base64url encoded sha256-hash of this string | |
193 | # into the DNS TXT record. | |
194 | sub key_authorization { | |
195 | my ($self, $token) = @_; | |
196 | return $token .'.'. $self->jwk_thumbprint(); | |
197 | } | |
198 | ||
199 | # JWS signing using the RS256 alg (RSA/SHA256). | |
200 | sub jws { | |
201 | my ($self, $use_jwk, $data, $url) = @_; | |
202 | my $key = $self->{key} | |
203 | or die "No key was generated yet\n"; | |
204 | ||
205 | my $payload = encode(tojs($data)); | |
206 | ||
207 | if (!defined($self->{nonce})) { | |
208 | my $method = $self->_method('newNonce'); | |
209 | $self->do(GET => $method); | |
210 | } | |
211 | ||
212 | # The acme protocol requires the actual request URL be in the protected | |
213 | # header. There is no unprotected header. | |
214 | my $protected = { | |
215 | alg => 'RS256', | |
216 | url => $url, | |
217 | nonce => $self->{nonce} // die "missing nonce\n" | |
218 | }; | |
219 | ||
220 | # header contains either | |
221 | # - kid, reference to account URL | |
222 | # - jwk, key itself | |
223 | # the latter is only allowed for | |
224 | # - creating accounts (no account URL yet) | |
225 | # - revoking certificates with the certificate key instead of account key | |
226 | if ($use_jwk) { | |
227 | $protected->{jwk} = $self->jwk(); | |
228 | } else { | |
229 | $protected->{kid} = $self->{location}; | |
230 | } | |
231 | ||
232 | $protected = encode(tojs($protected)); | |
233 | ||
234 | my $signdata = "$protected.$payload"; | |
235 | my $signature = encode($key->sign($signdata)); | |
236 | ||
237 | return { | |
238 | protected => $protected, | |
239 | payload => $payload, | |
240 | signature => $signature, | |
241 | }; | |
242 | } | |
243 | ||
244 | sub __get_result { | |
245 | my ($resp, $code, $plain) = @_; | |
246 | ||
5a94ed9d | 247 | die "expected code '$code', received '".$resp->code."'\n" |
11c08f14 FG |
248 | if $resp->code != $code; |
249 | ||
250 | return $plain ? $resp->decoded_content : fromjs($resp->decoded_content); | |
251 | } | |
252 | ||
253 | # Get the list of method URLs and query the directory if we have to. | |
254 | sub __get_methods { | |
255 | my ($self) = @_; | |
256 | if (my $methods = $self->{methods}) { | |
257 | return $methods; | |
258 | } | |
259 | my $r = $self->do(GET => $self->{directory}); | |
260 | my $methods = __get_result($r, 200); | |
261 | $self->fatal("unable to decode methods returned by directory - $@", $r) if $@; | |
262 | return ($self->{methods} = $methods); | |
263 | } | |
264 | ||
265 | # Get a method, causing the directory to be queried first if necessary. | |
266 | sub _method { | |
267 | my ($self, $method) = @_; | |
268 | my $methods = $self->__get_methods(); | |
269 | my $url = $methods->{$method} | |
270 | or die "no such method: $method\n"; | |
271 | return $url; | |
272 | } | |
273 | ||
274 | # Get $self->{account} with an error if we don't have one yet. | |
275 | sub _account { | |
276 | my ($self) = @_; | |
277 | my $account = $self->{account} | |
278 | // die "no account loaded\n"; | |
279 | return wantarray ? ($account, $self->{location}) : $account; | |
280 | } | |
281 | ||
282 | # debugging info | |
283 | sub list_methods { | |
284 | my ($self) = @_; | |
285 | my $methods = $self->__get_methods(); | |
286 | if (my $meta = $methods->{meta}) { | |
287 | print("(meta): $_ : $meta->{$_}\n") for sort keys %$meta; | |
288 | } | |
289 | print("$_ : $methods->{$_}\n") for sort grep {$_ ne 'meta'} keys %$methods; | |
290 | } | |
291 | ||
292 | # return (optional) meta directory entry. | |
293 | # this is public because it might contain the ToS, which should be displayed | |
294 | # and agreed to before creating an account | |
295 | sub get_meta { | |
296 | my ($self) = @_; | |
297 | my $methods = $self->__get_methods(); | |
298 | return $methods->{meta}; | |
299 | } | |
300 | ||
301 | # Common code between new_account and update_account | |
302 | sub __new_account { | |
303 | my ($self, $expected_code, $url, $new, %info) = @_; | |
304 | my $req = { | |
305 | %info, | |
306 | }; | |
307 | my $r = $self->do(POST => $url, $req, $new); | |
308 | eval { | |
309 | my $account = __get_result($r, $expected_code); | |
310 | if (!defined($self->{location})) { | |
311 | my $account_url = $r->header('Location') | |
312 | or die "did not receive an account URL\n"; | |
313 | $self->{location} = $account_url; | |
314 | } | |
315 | $self->{account} = $account; | |
316 | $self->save(); | |
317 | }; | |
318 | $self->fatal("POST to '$url' failed - $@", $r) if $@; | |
319 | return $self->{account}; | |
320 | } | |
321 | ||
322 | # Create a new account using data in %info. | |
323 | # Optionally pass $tos_url to agree to the given Terms of Service | |
324 | # POST to newAccount endpoint | |
325 | # Expects a '201 Created' reply | |
326 | # Saves and returns the account data | |
327 | sub new_account { | |
328 | my ($self, $tos_url, %info) = @_; | |
329 | my $url = $self->_method('newAccount'); | |
330 | ||
331 | if ($tos_url) { | |
332 | $self->{tos} = $tos_url; | |
333 | $info{termsOfServiceAgreed} = JSON::true; | |
334 | } | |
335 | ||
336 | return $self->__new_account(201, $url, 1, %info); | |
337 | } | |
338 | ||
339 | # Update existing account with new %info | |
340 | # POST to account URL | |
341 | # Expects a '200 OK' reply | |
342 | # Saves and returns updated account data | |
343 | sub update_account { | |
344 | my ($self, %info) = @_; | |
345 | my (undef, $url) = $self->_account; | |
346 | ||
347 | return $self->__new_account(200, $url, 0, %info); | |
348 | } | |
349 | ||
350 | # Retrieves existing account information | |
351 | # POST to account URL with empty body! | |
352 | # Expects a '200 OK' reply | |
353 | # Saves and returns updated account data | |
354 | sub get_account { | |
355 | my ($self) = @_; | |
356 | return $self->update_account(); | |
357 | } | |
358 | ||
359 | # Start a new order for one or more domains | |
360 | # POST to newOrder endpoint | |
361 | # Expects a '201 Created' reply | |
362 | # returns order URL and parsed order object, including authorization and finalize URLs | |
363 | sub new_order { | |
364 | my ($self, $domains) = @_; | |
365 | ||
366 | my $url = $self->_method('newOrder'); | |
367 | my $req = { | |
368 | identifiers => [ map { { type => 'dns', value => $_ } } @$domains ], | |
369 | }; | |
370 | ||
371 | my $r = $self->do(POST => $url, $req); | |
372 | my ($order_url, $order); | |
373 | eval { | |
374 | $order_url = $r->header('Location') | |
5a94ed9d | 375 | or die "did not receive an order URL\n"; |
11c08f14 FG |
376 | $order = __get_result($r, 201) |
377 | }; | |
378 | $self->fatal("POST to '$url' failed - $@", $r) if $@; | |
379 | return ($order_url, $order); | |
380 | } | |
381 | ||
382 | # Finalize order after all challenges have been validated | |
383 | # POST to order's finalize URL | |
384 | # Expects a '200 OK' reply | |
385 | # returns (potentially updated) order object | |
386 | sub finalize_order { | |
387 | my ($self, $order, $csr) = @_; | |
388 | ||
389 | my $req = { | |
390 | csr => encode($csr), | |
391 | }; | |
392 | my $r = $self->do(POST => $order->{finalize}, $req); | |
393 | my $return = eval { __get_result($r, 200); }; | |
394 | $self->fatal("POST to '$order->{finalize}' failed - $@", $r) if $@; | |
395 | return $return; | |
396 | } | |
397 | ||
398 | # Get order status | |
399 | # GET to order URL | |
400 | # Expects a '200 OK' reply | |
401 | # returns order object | |
402 | sub get_order { | |
403 | my ($self, $order_url) = @_; | |
404 | my $r = $self->do(GET => $order_url); | |
405 | my $return = eval { __get_result($r, 200); }; | |
406 | $self->fatal("GET of '$order_url' failed - $@", $r) if $@; | |
407 | return $return; | |
408 | } | |
409 | ||
410 | # Gets authorization object | |
411 | # GET to authorization URL | |
412 | # Expects a '200 OK' reply | |
413 | # returns authorization object, including challenges array | |
414 | sub get_authorization { | |
415 | my ($self, $auth_url) = @_; | |
416 | ||
417 | my $r = $self->do(GET => $auth_url); | |
418 | my $return = eval { __get_result($r, 200); }; | |
419 | $self->fatal("GET of '$auth_url' failed - $@", $r) if $@; | |
420 | return $return; | |
421 | } | |
422 | ||
423 | # Deactivates existing authorization | |
424 | # POST to authorization URL | |
425 | # Expects a '200 OK' reply | |
426 | # returns updated authorization object | |
427 | sub deactivate_authorization { | |
428 | my ($self, $auth_url) = @_; | |
429 | ||
430 | my $req = { | |
431 | status => 'deactivated', | |
432 | }; | |
433 | my $r = $self->do(POST => $auth_url, $req); | |
434 | my $return = eval { __get_result($r, 200); }; | |
435 | $self->fatal("POST to '$auth_url' failed - $@", $r) if $@; | |
436 | return $return; | |
437 | } | |
438 | ||
439 | # Get certificate | |
440 | # GET to order's certificate URL | |
441 | # Expects a '200 OK' reply | |
442 | # returns certificate chain in PEM format | |
443 | sub get_certificate { | |
444 | my ($self, $order) = @_; | |
445 | ||
446 | $self->fatal("no certificate URL available (yet?)", $order) | |
447 | if !$order->{certificate}; | |
448 | ||
449 | my $r = $self->do(GET => $order->{certificate}); | |
450 | my $return = eval { __get_result($r, 200, 1); }; | |
451 | $self->fatal("GET of '$order->{certificate}' failed - $@", $r) if $@; | |
452 | return $return; | |
453 | } | |
454 | ||
455 | # Revoke given certificate | |
456 | # POST to revokeCert endpoint | |
457 | # currently only supports revokation with account key | |
458 | # $certificate can either be PEM or DER encoded | |
459 | # Expects a '200 OK' reply | |
460 | sub revoke_certificate { | |
461 | my ($self, $certificate, $reason) = @_; | |
462 | ||
463 | my $url = $self->_method('revokeCert'); | |
464 | ||
465 | if ($certificate =~ /^-----BEGIN CERTIFICATE-----/) { | |
466 | $certificate = PVE::Certificate::pem_to_der($certificate); | |
467 | } | |
468 | ||
469 | my $req = { | |
470 | certificate => encode($certificate), | |
471 | reason => $reason // 0, | |
472 | }; | |
473 | # TODO: set use_jwk if revoking with certificate key | |
474 | my $r = $self->do(POST => $url, $req); | |
475 | eval { | |
5a94ed9d | 476 | die "unexpected code $r->code\n" if $r->code != 200; |
11c08f14 FG |
477 | }; |
478 | $self->fatal("POST to '$url' failed - $@", $r) if $@; | |
479 | } | |
480 | ||
481 | # Request validation of challenge | |
482 | # POST to challenge URL | |
483 | # call after validation has been setup | |
484 | # returns (potentially updated) challenge object | |
485 | sub request_challenge_validation { | |
486 | my ($self, $url, $key_authorization) = @_; | |
487 | ||
488 | my $req = { keyAuthorization => $key_authorization }; | |
489 | ||
490 | my $r = $self->do(POST => $url, $req); | |
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'); | |
5a94ed9d | 506 | my $content = defined($data) ? $self->jws($use_jwk, $data, $url) : undef; |
11c08f14 FG |
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') { | |
5a94ed9d | 520 | warn("bad Nonce, retrying\n"); |
11c08f14 FG |
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; |