]> git.proxmox.com Git - pve-common.git/blame - src/PVE/ACME.pm
Tools: add fsync sycall
[pve-common.git] / src / PVE / ACME.pm
CommitLineData
11c08f14
FG
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
26Crypt::OpenSSL::RSA->import_random_seed();
27
28my $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
60sub encode($) { # acme requires 'base64url' encoding
61 return encode_base64url($_[0]);
62}
63
64sub tojs($;%) { # shortcut for to_json with utf8=>1
65 my ($data, %data) = @_;
66 return to_json($data, { utf8 => 1, %data });
67}
68
69sub fromjs($) {
70 return from_json($_[0]);
71}
72
73sub 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
88sub 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
113my $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!
121sub 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
131my @SAVED_VALUES = qw(location account tos debug directory);
132# Serialize persistent parts of $self to $self->{path} as JSON
133sub 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
151sub 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.
169sub 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.
185sub 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.
194sub key_authorization {
195 my ($self, $token) = @_;
196 return $token .'.'. $self->jwk_thumbprint();
197}
198
199# JWS signing using the RS256 alg (RSA/SHA256).
200sub 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
244sub __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.
254sub __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.
266sub _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.
275sub _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
283sub 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
295sub 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
302sub __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
327sub 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
343sub 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
354sub 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
363sub 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
386sub 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
402sub 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
414sub 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
427sub 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
443sub 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
460sub 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
485sub 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)
499sub 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
5331;