]> git.proxmox.com Git - proxmox-acme.git/blame - src/PVE/ACME.pm
support downloading alternate chains
[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
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
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($) {
a5320155
SI
72 my ($data) = @_;
73 ($data) = ($data =~ /^(.*)$/s); # untaint from_json croaks on error anyways.
74 return from_json($data);
5460050d
WL
75}
76
77sub fatal($$;$$) {
78 my ($self, $msg, $dump, $noerr) = @_;
79
80 warn Dumper($dump), "\n" if $self->{debug} && $dump;
81 if ($noerr) {
82 warn "$msg\n";
83 } else {
84 die "$msg\n";
85 }
86}
87
88# Implementation
89
90# $path: account JSON file
91# $directory: the ACME directory URL used to find method URLs
92sub new($$$) {
93 my ($class, $path, $directory) = @_;
94
95 $directory //= $LETSENCRYPT_STAGING;
96
97 my $ua = LWP::UserAgent->new();
98 $ua->env_proxy();
99 $ua->agent('pve-acme/0.1');
100 $ua->protocols_allowed(['https']);
101
102 my $self = {
103 ua => $ua,
104 path => $path,
105 directory => $directory,
106 nonce => undef,
107 key => undef,
108 location => undef,
109 account => undef,
110 tos => undef,
111 };
112
113 return bless $self, $class;
114}
115
116# RS256: PKCS#1 padding, no OAEP, SHA256
117my $configure_key = sub {
118 my ($key) = @_;
119 $key->use_pkcs1_padding();
120 $key->use_sha256_hash();
121};
122
123# Create account key with $keybits bits
124# use instead of load, overwrites existing account JSON file!
125sub init {
126 my ($self, $keybits) = @_;
127 die "Already have a key\n" if defined($self->{key});
128 $keybits //= 4096;
129 my $key = Crypt::OpenSSL::RSA->generate_key($keybits);
130 $configure_key->($key);
131 $self->{key} = $key;
132 $self->save();
133}
134
135my @SAVED_VALUES = qw(location account tos debug directory);
136# Serialize persistent parts of $self to $self->{path} as JSON
137sub save {
138 my ($self) = @_;
139 my $o = {};
140 my $keystr;
141 if (my $key = $self->{key}) {
142 $keystr = $key->get_private_key_string();
143 $o->{key} = $keystr;
144 }
145 for my $k (@SAVED_VALUES) {
146 my $v = $self->{$k} // next;
147 $o->{$k} = $v;
148 }
149 # pretty => 1 for readability
150 # canonical => 1 to reduce churn
151 file_set_contents($self->{path}, tojs($o, pretty => 1, canonical => 1));
152}
153
154# Load serialized account JSON file into $self
155sub load {
156 my ($self) = @_;
157 return if $self->{loaded};
158 $self->{loaded} = 1;
159 my $raw = file_get_contents($self->{path});
160 if ($raw =~ m/^(.*)$/s) { $raw = $1; } # untaint
161 my $data = fromjs($raw);
162 $self->{$_} = $data->{$_} for @SAVED_VALUES;
163 if (defined(my $keystr = $data->{key})) {
164 my $key = Crypt::OpenSSL::RSA->new_private_key($keystr);
165 $configure_key->($key);
166 $self->{key} = $key;
167 }
168}
169
170# The 'jwk' object needs the key type, key parameters and the usage,
171# except for when we want to take the JWK-Thumbprint, then the usage
172# must not be included.
173sub jwk {
174 my ($self, $pure) = @_;
175 my $key = $self->{key}
176 or die "No key was generated yet\n";
177 my ($n, $e) = $key->get_key_parameters();
178 return {
179 kty => 'RSA',
180 ($pure ? () : (use => 'sig')), # for thumbprints
181 n => encode($n->to_bin),
182 e => encode($e->to_bin),
183 };
184}
185
186# The thumbprint is a sha256 hash of the lexicographically sorted (iow.
187# canonical) condensed json string of the JWK object which gets base64url
188# encoded.
189sub jwk_thumbprint {
190 my ($self) = @_;
191 my $jwk = $self->jwk(1); # $pure = 1
192 return encode(sha256(tojs($jwk, canonical=>1))); # canonical sorts
193}
194
195# A key authorization string in acme is a challenge token dot-connected with
196# a JWK Thumbprint. You put the base64url encoded sha256-hash of this string
197# into the DNS TXT record.
198sub key_authorization {
199 my ($self, $token) = @_;
200 return $token .'.'. $self->jwk_thumbprint();
201}
202
203# JWS signing using the RS256 alg (RSA/SHA256).
204sub jws {
205 my ($self, $use_jwk, $data, $url) = @_;
206 my $key = $self->{key}
207 or die "No key was generated yet\n";
208
209 my $payload = $data ne '' ? encode(tojs($data)) : $data;
210
211 if (!defined($self->{nonce})) {
212 my $method = $self->_method('newNonce');
213 $self->do(GET => $method);
214 }
215
216 # The acme protocol requires the actual request URL be in the protected
217 # header. There is no unprotected header.
218 my $protected = {
219 alg => 'RS256',
220 url => $url,
221 nonce => $self->{nonce} // die "missing nonce\n"
222 };
223
224 # header contains either
225 # - kid, reference to account URL
226 # - jwk, key itself
227 # the latter is only allowed for
228 # - creating accounts (no account URL yet)
229 # - revoking certificates with the certificate key instead of account key
230 if ($use_jwk) {
231 $protected->{jwk} = $self->jwk();
232 } else {
233 $protected->{kid} = $self->{location};
234 }
235
236 $protected = encode(tojs($protected));
237
238 my $signdata = "$protected.$payload";
239 my $signature = encode($key->sign($signdata));
240
241 return {
242 protected => $protected,
243 payload => $payload,
244 signature => $signature,
245 };
246}
247
248sub __get_result {
249 my ($resp, $code, $plain) = @_;
250
251 die "expected code '$code', received '".$resp->code."'\n"
252 if $resp->code != $code;
253
254 return $plain ? $resp->decoded_content : fromjs($resp->decoded_content);
255}
256
257# Get the list of method URLs and query the directory if we have to.
258sub __get_methods {
259 my ($self) = @_;
260 if (my $methods = $self->{methods}) {
261 return $methods;
262 }
263 my $r = $self->do(GET => $self->{directory});
264 my $methods = __get_result($r, 200);
265 $self->fatal("unable to decode methods returned by directory - $@", $r) if $@;
266 return ($self->{methods} = $methods);
267}
268
269# Get a method, causing the directory to be queried first if necessary.
270sub _method {
271 my ($self, $method) = @_;
272 my $methods = $self->__get_methods();
273 my $url = $methods->{$method}
274 or die "no such method: $method\n";
275 return $url;
276}
277
278# Get $self->{account} with an error if we don't have one yet.
279sub _account {
280 my ($self) = @_;
281 my $account = $self->{account}
282 // die "no account loaded\n";
283 return wantarray ? ($account, $self->{location}) : $account;
284}
285
286# debugging info
287sub list_methods {
288 my ($self) = @_;
289 my $methods = $self->__get_methods();
290 if (my $meta = $methods->{meta}) {
291 print("(meta): $_ : $meta->{$_}\n") for sort keys %$meta;
292 }
293 print("$_ : $methods->{$_}\n") for sort grep {$_ ne 'meta'} keys %$methods;
294}
295
296# return (optional) meta directory entry.
297# this is public because it might contain the ToS, which should be displayed
298# and agreed to before creating an account
299sub get_meta {
300 my ($self) = @_;
301 my $methods = $self->__get_methods();
302 return $methods->{meta};
303}
304
305# Common code between new_account and update_account
306sub __new_account {
307 my ($self, $expected_code, $url, $new, %info) = @_;
308 my $req = {
309 %info,
310 };
311 my $r = $self->do(POST => $url, $req, $new);
312 eval {
313 my $account = __get_result($r, $expected_code);
314 if (!defined($self->{location})) {
315 my $account_url = $r->header('Location')
316 or die "did not receive an account URL\n";
317 $self->{location} = $account_url;
318 }
319 $self->{account} = $account;
320 $self->save();
321 };
322 $self->fatal("POST to '$url' failed - $@", $r) if $@;
323 return $self->{account};
324}
325
326# Create a new account using data in %info.
327# Optionally pass $tos_url to agree to the given Terms of Service
328# POST to newAccount endpoint
329# Expects a '201 Created' reply
330# Saves and returns the account data
331sub new_account {
332 my ($self, $tos_url, %info) = @_;
333 my $url = $self->_method('newAccount');
334
335 if ($tos_url) {
336 $self->{tos} = $tos_url;
337 $info{termsOfServiceAgreed} = JSON::true;
338 }
339
340 return $self->__new_account(201, $url, 1, %info);
341}
342
343# Update existing account with new %info
344# POST to account URL
345# Expects a '200 OK' reply
346# Saves and returns updated account data
347sub update_account {
348 my ($self, %info) = @_;
349 my (undef, $url) = $self->_account;
350
351 return $self->__new_account(200, $url, 0, %info);
352}
353
354# Retrieves existing account information
355# POST to account URL with empty body!
356# Expects a '200 OK' reply
357# Saves and returns updated account data
358sub get_account {
359 my ($self) = @_;
360 return $self->update_account();
361}
362
363# Start a new order for one or more domains
364# POST to newOrder endpoint
365# Expects a '201 Created' reply
366# returns order URL and parsed order object, including authorization and finalize URLs
367sub new_order {
368 my ($self, $domains) = @_;
369
370 my $url = $self->_method('newOrder');
371 my $req = {
372 identifiers => [ map { { type => 'dns', value => $_ } } @$domains ],
373 };
374
375 my $r = $self->do(POST => $url, $req);
376 my ($order_url, $order);
377 eval {
378 $order_url = $r->header('Location')
379 or die "did not receive an order URL\n";
380 $order = __get_result($r, 201)
381 };
382 $self->fatal("POST to '$url' failed - $@", $r) if $@;
383 return ($order_url, $order);
384}
385
386# Finalize order after all challenges have been validated
387# POST to order's finalize URL
388# Expects a '200 OK' reply
389# returns (potentially updated) order object
390sub finalize_order {
391 my ($self, $order, $csr) = @_;
392
393 my $req = {
394 csr => encode($csr),
395 };
396 my $r = $self->do(POST => $order->{finalize}, $req);
397 my $return = eval { __get_result($r, 200); };
398 $self->fatal("POST to '$order->{finalize}' failed - $@", $r) if $@;
399 return $return;
400}
401
402# Get order status
403# GET-as-POST to order URL
404# Expects a '200 OK' reply
405# returns order object
406sub get_order {
407 my ($self, $order_url) = @_;
408 my $r = $self->do(POST => $order_url, '');
409 my $return = eval { __get_result($r, 200); };
410 $self->fatal("POST of '$order_url' failed - $@", $r) if $@;
411 return $return;
412}
413
414# Gets authorization object
415# GET-as-POST to authorization URL
416# Expects a '200 OK' reply
417# returns authorization object, including challenges array
418sub get_authorization {
419 my ($self, $auth_url) = @_;
420
421 my $r = $self->do(POST => $auth_url, '');
422 my $return = eval { __get_result($r, 200); };
423 $self->fatal("POST of '$auth_url' failed - $@", $r) if $@;
424 return $return;
425}
426
427# Deactivates existing authorization
428# POST to authorization URL
429# Expects a '200 OK' reply
430# returns updated authorization object
431sub deactivate_authorization {
432 my ($self, $auth_url) = @_;
433
434 my $req = {
435 status => 'deactivated',
436 };
437 my $r = $self->do(POST => $auth_url, $req);
438 my $return = eval { __get_result($r, 200); };
439 $self->fatal("POST to '$auth_url' failed - $@", $r) if $@;
440 return $return;
441}
442
443# Get certificate
444# GET-as-POST to order's certificate URL
2307c38b 445# if $root is specified, attempts to find a matching (alternate) chain
5460050d
WL
446# Expects a '200 OK' reply
447# returns certificate chain in PEM format
448sub get_certificate {
2307c38b 449 my ($self, $order, $root) = @_;
5460050d
WL
450
451 $self->fatal("no certificate URL available (yet?)", $order)
452 if !$order->{certificate};
453
2307c38b
FG
454 my $check_root = sub {
455 my ($chain) = @_;
456
457 my @certs = PVE::Certificate::split_pem($chain);
458 my $root_pem = $certs[-1];
459
460 my ($file, $fh) = PVE::Tools::tempfile_contents($root_pem);
461 my $info = PVE::Certificate::get_certificate_info($file);
462
463 return defined($info->{issuer}) && $info->{issuer} =~ m/\Q$root\E/i;
464 };
465
5460050d 466 my $r = $self->do(POST => $order->{certificate}, '');
a5320155 467 my $return = eval {
2307c38b 468 # default chain
a5320155 469 my $res = __get_result($r, 200, 1);
2307c38b
FG
470 if ($root && !$check_root->($res)) {
471 # alternate chains if requested and default didn't match
472 $res = undef;
473 my @links = $r->header('link');
474 for my $link (@links) {
475 if ($link =~ /^<(.*)>;rel="alternate"$/) {
476 my $url = $1;
477 my $chain = eval { __get_result($self->do(POST => $url, ''), 200, 1); };
478 die "failed to retrieve alternate chain from '$url' - $@\n" if $@;
479 if ($check_root->($chain)) {
480 $res = $chain;
481 last;
482 }
483 }
484 }
485 die "no matching alternate chain for '$root' returned by server\n"
486 if !defined($res);
487 }
488
a5320155
SI
489 if ($res =~ /^(-----BEGIN CERTIFICATE-----)(.+)(-----END CERTIFICATE-----)$/s) { # untaint
490 return $1 . $2 . $3;
491 }
492 die "Server reply does not look like a PEM encoded certificate\n";
493 };
5460050d
WL
494 $self->fatal("POST of '$order->{certificate}' failed - $@", $r) if $@;
495 return $return;
496}
497
498# Revoke given certificate
499# POST to revokeCert endpoint
500# currently only supports revokation with account key
501# $certificate can either be PEM or DER encoded
502# Expects a '200 OK' reply
503sub revoke_certificate {
504 my ($self, $certificate, $reason) = @_;
505
506 my $url = $self->_method('revokeCert');
507
508 if ($certificate =~ /^-----BEGIN CERTIFICATE-----/) {
509 $certificate = PVE::Certificate::pem_to_der($certificate);
510 }
511
512 my $req = {
513 certificate => encode($certificate),
514 reason => $reason // 0,
515 };
516 # TODO: set use_jwk if revoking with certificate key
517 my $r = $self->do(POST => $url, $req);
518 eval {
519 die "unexpected code $r->code\n" if $r->code != 200;
520 };
521 $self->fatal("POST to '$url' failed - $@", $r) if $@;
522}
523
524# Request validation of challenge
525# POST to challenge URL
526# call after validation has been setup
527# returns (potentially updated) challenge object
528sub request_challenge_validation {
109fc3fd 529 my ($self, $url) = @_;
5460050d 530
109fc3fd 531 my $r = $self->do(POST => $url, {});
5460050d
WL
532 my $return = eval { __get_result($r, 200); };
533 $self->fatal("POST to '$url' failed - $@", $r) if $@;
534 return $return;
535}
536
537# actually 'do' a $method request on $url
538# $data: input for JWS, optional
539# $use_jwk: use JWK instead of KID in JWD (see sub jws)
540sub do {
541 my ($self, $method, $url, $data, $use_jwk) = @_;
542
543 $self->fatal("Error: can't $method to empty URL") if !$url || $url eq '';
544
545 my $headers = HTTP::Headers->new();
546 $headers->header('Content-Type' => 'application/jose+json');
547 my $content = defined($data) ? $self->jws($use_jwk, $data, $url) : undef;
548 my $request;
549 if (defined($content)) {
550 $content = tojs($content);
551 $request = HTTP::Request->new($method, $url, $headers, $content);
552 } else {
553 $request = HTTP::Request->new($method, $url, $headers);
554 }
555 my $res = $self->{ua}->request($request);
556 if (!$res->is_success) {
557 # check for nonce rejection
558 if ($res->code == 400 && $res->decoded_content) {
559 my $parsed_content = fromjs($res->decoded_content);
560 if ($parsed_content->{type} eq 'urn:ietf:params:acme:error:badNonce') {
561 warn("bad Nonce, retrying\n");
562 $self->{nonce} = $res->header('Replay-Nonce');
563 return $self->do($method, $url, $data, $use_jwk);
564 }
565 }
566 $self->fatal("Error: $method to $url\n".$res->decoded_content, $res);
567 }
568 if (my $nonce = $res->header('Replay-Nonce')) {
569 $self->{nonce} = $nonce;
570 }
571 return $res;
572}
573
5741;