]> git.proxmox.com Git - proxmox-acme.git/blame - src/PVE/ACME.pm
Implement function to resolve all subplugins
[proxmox-acme.git] / src / PVE / ACME.pm
CommitLineData
5460050d
WL
1package PVE::ACME;
2
3use strict;
4use warnings;
5
6use POSIX;
7
8use Data::Dumper;
9use Date::Parse;
10use MIME::Base64 qw(encode_base64url);
11use File::Path qw(make_path);
12use JSON;
13use Digest::SHA qw(sha256 sha256_hex);
14
15use HTTP::Request;
16use LWP::UserAgent;
17
18use Crypt::OpenSSL::RSA;
19
20use PVE::Certificate;
21use PVE::Tools qw(
22file_set_contents
23file_get_contents
24);
25
98b96d9e
WL
26use PVE::ACME::DNSChallenge;
27
5460050d
WL
28Crypt::OpenSSL::RSA->import_random_seed();
29
30my $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
49# e) $acme->request_challenge_validation($challenge->{url}, $key_auth);
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
62sub encode($) { # acme requires 'base64url' encoding
63 return encode_base64url($_[0]);
64}
65
66sub tojs($;%) { # shortcut for to_json with utf8=>1
67 my ($data, %data) = @_;
68 return to_json($data, { utf8 => 1, %data });
69}
70
71sub fromjs($) {
72 return from_json($_[0]);
73}
74
75sub 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
90sub 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
115my $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!
123sub 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
133my @SAVED_VALUES = qw(location account tos debug directory);
134# Serialize persistent parts of $self to $self->{path} as JSON
135sub 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
153sub 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.
171sub 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.
187sub 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.
196sub key_authorization {
197 my ($self, $token) = @_;
198 return $token .'.'. $self->jwk_thumbprint();
199}
200
201# JWS signing using the RS256 alg (RSA/SHA256).
202sub 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
246sub __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.
256sub __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.
268sub _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.
277sub _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
285sub 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
297sub 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
304sub __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
329sub 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
345sub 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
356sub 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
365sub 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
388sub 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
404sub 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
416sub 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
429sub 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
445sub 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
462sub 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
487sub request_challenge_validation {
488 my ($self, $url, $key_authorization) = @_;
489
490 my $req = { keyAuthorization => $key_authorization };
491
492 my $r = $self->do(POST => $url, $req);
493 my $return = eval { __get_result($r, 200); };
494 $self->fatal("POST to '$url' failed - $@", $r) if $@;
495 return $return;
496}
497
16925001
WL
498# return all availible subplugins from the plugins
499sub get_subplugins {
500
501 my $tmp = [];
502 my $plugins = PVE::ACME::Challenge->lookup_types();
503
504 foreach my $plugin_name (@$plugins) {
505 my $plugin = PVE::ACME::Challenge->lookup($plugin_name);
506 push @$tmp, $plugin->get_subplugins();
507 }
508
509 my $subplugins = [];
510 foreach my $array (@$tmp) {
511 foreach my $subplugin ( @$array) {
512 push @$subplugins, $subplugin;
513 }
514 }
515
516 return $subplugins;
517}
518
5460050d
WL
519# actually 'do' a $method request on $url
520# $data: input for JWS, optional
521# $use_jwk: use JWK instead of KID in JWD (see sub jws)
522sub do {
523 my ($self, $method, $url, $data, $use_jwk) = @_;
524
525 $self->fatal("Error: can't $method to empty URL") if !$url || $url eq '';
526
527 my $headers = HTTP::Headers->new();
528 $headers->header('Content-Type' => 'application/jose+json');
529 my $content = defined($data) ? $self->jws($use_jwk, $data, $url) : undef;
530 my $request;
531 if (defined($content)) {
532 $content = tojs($content);
533 $request = HTTP::Request->new($method, $url, $headers, $content);
534 } else {
535 $request = HTTP::Request->new($method, $url, $headers);
536 }
537 my $res = $self->{ua}->request($request);
538 if (!$res->is_success) {
539 # check for nonce rejection
540 if ($res->code == 400 && $res->decoded_content) {
541 my $parsed_content = fromjs($res->decoded_content);
542 if ($parsed_content->{type} eq 'urn:ietf:params:acme:error:badNonce') {
543 warn("bad Nonce, retrying\n");
544 $self->{nonce} = $res->header('Replay-Nonce');
545 return $self->do($method, $url, $data, $use_jwk);
546 }
547 }
548 $self->fatal("Error: $method to $url\n".$res->decoded_content, $res);
549 }
550 if (my $nonce = $res->header('Replay-Nonce')) {
551 $self->{nonce} = $nonce;
552 }
553 return $res;
554}
555
5561;