From: Wolfgang Link Date: Thu, 16 Apr 2020 05:18:20 +0000 (+0200) Subject: Move the code to proxmox-acme and add a dependency on it. X-Git-Url: https://git.proxmox.com/?p=pve-common.git;a=commitdiff_plain;h=12b0d5a959df28625698fb9f350b6451bde8e810;hp=7db4e5d763d734bbe45dc1773543b028298068f9 Move the code to proxmox-acme and add a dependency on it. Signed-off-by: Wolfgang Link --- diff --git a/debian/control b/debian/control index c467dd6..8faa22e 100644 --- a/debian/control +++ b/debian/control @@ -32,6 +32,7 @@ Depends: libclone-perl, libstring-shellquote-perl, liburi-perl, libwww-perl, + libproxmox-acme-perl, ${misc:Depends}, ${perl:Depends}, Breaks: ifupdown2 (<< 2.0.1-1+pve5), diff --git a/src/PVE/ACME.pm b/src/PVE/ACME.pm deleted file mode 100644 index 114eb41..0000000 --- a/src/PVE/ACME.pm +++ /dev/null @@ -1,533 +0,0 @@ -package PVE::ACME; - -use strict; -use warnings; - -use POSIX; - -use Data::Dumper; -use Date::Parse; -use MIME::Base64 qw(encode_base64url); -use File::Path qw(make_path); -use JSON; -use Digest::SHA qw(sha256 sha256_hex); - -use HTTP::Request; -use LWP::UserAgent; - -use Crypt::OpenSSL::RSA; - -use PVE::Certificate; -use PVE::Tools qw( -file_set_contents -file_get_contents -); - -Crypt::OpenSSL::RSA->import_random_seed(); - -my $LETSENCRYPT_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory'; - -### ACME library (compatible with Let's Encrypt v2 API) -# -# sample usage: -# -# 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL'); -# 2) $acme->init(4096); # generate account key -# 4) my $tos_url = $acme->get_meta()->{termsOfService}; # optional, display if applicable -# 5) $acme->new_account($tos_url, contact => ['mailto:example@example.com']); -# -# 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL'); -# 2) $acme->load(); -# 3) my ($order_url, $order) = $acme->new_order(['foo.example.com', 'bar.example.com']); -# 4) # repeat a-f for each $auth_url in $order->{authorizations} -# a) my $authorization = $acme->get_authorization($auth_url); -# b) # pick $challenge from $authorization->{challenges} according to desired type -# c) my $key_auth = $acme->key_authorization($challenge->{token}); -# d) # setup challenge validation according to specification -# e) $acme->request_challenge_validation($challenge->{url}, $key_auth); -# f) # poll $acme->get_authorization($auth_url) until status is 'valid' -# 5) # generate CSR in PEM format -# 6) $acme->finalize_order($order, $csr); -# 7) # poll $acme->get_order($order_url) until status is 'valid' -# 8) my $cert = $acme->get_certificate($order); -# 9) # $key is path to key file, $cert contains PEM-encoded certificate chain -# -# 1) my $acme = PVE::ACME->new('path/to/account.json', 'API directory URL'); -# 2) $acme->load(); -# 3) $acme->revoke_certificate($cert); - -# Tools -sub encode($) { # acme requires 'base64url' encoding - return encode_base64url($_[0]); -} - -sub tojs($;%) { # shortcut for to_json with utf8=>1 - my ($data, %data) = @_; - return to_json($data, { utf8 => 1, %data }); -} - -sub fromjs($) { - return from_json($_[0]); -} - -sub fatal($$;$$) { - my ($self, $msg, $dump, $noerr) = @_; - - warn Dumper($dump), "\n" if $self->{debug} && $dump; - if ($noerr) { - warn "$msg\n"; - } else { - die "$msg\n"; - } -} - -# Implementation - -# $path: account JSON file -# $directory: the ACME directory URL used to find method URLs -sub new($$$) { - my ($class, $path, $directory) = @_; - - $directory //= $LETSENCRYPT_STAGING; - - my $ua = LWP::UserAgent->new(); - $ua->env_proxy(); - $ua->agent('pve-acme/0.1'); - $ua->protocols_allowed(['https']); - - my $self = { - ua => $ua, - path => $path, - directory => $directory, - nonce => undef, - key => undef, - location => undef, - account => undef, - tos => undef, - }; - - return bless $self, $class; -} - -# RS256: PKCS#1 padding, no OAEP, SHA256 -my $configure_key = sub { - my ($key) = @_; - $key->use_pkcs1_padding(); - $key->use_sha256_hash(); -}; - -# Create account key with $keybits bits -# use instead of load, overwrites existing account JSON file! -sub init { - my ($self, $keybits) = @_; - die "Already have a key\n" if defined($self->{key}); - $keybits //= 4096; - my $key = Crypt::OpenSSL::RSA->generate_key($keybits); - $configure_key->($key); - $self->{key} = $key; - $self->save(); -} - -my @SAVED_VALUES = qw(location account tos debug directory); -# Serialize persistent parts of $self to $self->{path} as JSON -sub save { - my ($self) = @_; - my $o = {}; - my $keystr; - if (my $key = $self->{key}) { - $keystr = $key->get_private_key_string(); - $o->{key} = $keystr; - } - for my $k (@SAVED_VALUES) { - my $v = $self->{$k} // next; - $o->{$k} = $v; - } - # pretty => 1 for readability - # canonical => 1 to reduce churn - file_set_contents($self->{path}, tojs($o, pretty => 1, canonical => 1)); -} - -# Load serialized account JSON file into $self -sub load { - my ($self) = @_; - return if $self->{loaded}; - $self->{loaded} = 1; - my $raw = file_get_contents($self->{path}); - if ($raw =~ m/^(.*)$/s) { $raw = $1; } # untaint - my $data = fromjs($raw); - $self->{$_} = $data->{$_} for @SAVED_VALUES; - if (defined(my $keystr = $data->{key})) { - my $key = Crypt::OpenSSL::RSA->new_private_key($keystr); - $configure_key->($key); - $self->{key} = $key; - } -} - -# The 'jwk' object needs the key type, key parameters and the usage, -# except for when we want to take the JWK-Thumbprint, then the usage -# must not be included. -sub jwk { - my ($self, $pure) = @_; - my $key = $self->{key} - or die "No key was generated yet\n"; - my ($n, $e) = $key->get_key_parameters(); - return { - kty => 'RSA', - ($pure ? () : (use => 'sig')), # for thumbprints - n => encode($n->to_bin), - e => encode($e->to_bin), - }; -} - -# The thumbprint is a sha256 hash of the lexicographically sorted (iow. -# canonical) condensed json string of the JWK object which gets base64url -# encoded. -sub jwk_thumbprint { - my ($self) = @_; - my $jwk = $self->jwk(1); # $pure = 1 - return encode(sha256(tojs($jwk, canonical=>1))); # canonical sorts -} - -# A key authorization string in acme is a challenge token dot-connected with -# a JWK Thumbprint. You put the base64url encoded sha256-hash of this string -# into the DNS TXT record. -sub key_authorization { - my ($self, $token) = @_; - return $token .'.'. $self->jwk_thumbprint(); -} - -# JWS signing using the RS256 alg (RSA/SHA256). -sub jws { - my ($self, $use_jwk, $data, $url) = @_; - my $key = $self->{key} - or die "No key was generated yet\n"; - - my $payload = $data ne '' ? encode(tojs($data)) : $data; - - if (!defined($self->{nonce})) { - my $method = $self->_method('newNonce'); - $self->do(GET => $method); - } - - # The acme protocol requires the actual request URL be in the protected - # header. There is no unprotected header. - my $protected = { - alg => 'RS256', - url => $url, - nonce => $self->{nonce} // die "missing nonce\n" - }; - - # header contains either - # - kid, reference to account URL - # - jwk, key itself - # the latter is only allowed for - # - creating accounts (no account URL yet) - # - revoking certificates with the certificate key instead of account key - if ($use_jwk) { - $protected->{jwk} = $self->jwk(); - } else { - $protected->{kid} = $self->{location}; - } - - $protected = encode(tojs($protected)); - - my $signdata = "$protected.$payload"; - my $signature = encode($key->sign($signdata)); - - return { - protected => $protected, - payload => $payload, - signature => $signature, - }; -} - -sub __get_result { - my ($resp, $code, $plain) = @_; - - die "expected code '$code', received '".$resp->code."'\n" - if $resp->code != $code; - - return $plain ? $resp->decoded_content : fromjs($resp->decoded_content); -} - -# Get the list of method URLs and query the directory if we have to. -sub __get_methods { - my ($self) = @_; - if (my $methods = $self->{methods}) { - return $methods; - } - my $r = $self->do(GET => $self->{directory}); - my $methods = __get_result($r, 200); - $self->fatal("unable to decode methods returned by directory - $@", $r) if $@; - return ($self->{methods} = $methods); -} - -# Get a method, causing the directory to be queried first if necessary. -sub _method { - my ($self, $method) = @_; - my $methods = $self->__get_methods(); - my $url = $methods->{$method} - or die "no such method: $method\n"; - return $url; -} - -# Get $self->{account} with an error if we don't have one yet. -sub _account { - my ($self) = @_; - my $account = $self->{account} - // die "no account loaded\n"; - return wantarray ? ($account, $self->{location}) : $account; -} - -# debugging info -sub list_methods { - my ($self) = @_; - my $methods = $self->__get_methods(); - if (my $meta = $methods->{meta}) { - print("(meta): $_ : $meta->{$_}\n") for sort keys %$meta; - } - print("$_ : $methods->{$_}\n") for sort grep {$_ ne 'meta'} keys %$methods; -} - -# return (optional) meta directory entry. -# this is public because it might contain the ToS, which should be displayed -# and agreed to before creating an account -sub get_meta { - my ($self) = @_; - my $methods = $self->__get_methods(); - return $methods->{meta}; -} - -# Common code between new_account and update_account -sub __new_account { - my ($self, $expected_code, $url, $new, %info) = @_; - my $req = { - %info, - }; - my $r = $self->do(POST => $url, $req, $new); - eval { - my $account = __get_result($r, $expected_code); - if (!defined($self->{location})) { - my $account_url = $r->header('Location') - or die "did not receive an account URL\n"; - $self->{location} = $account_url; - } - $self->{account} = $account; - $self->save(); - }; - $self->fatal("POST to '$url' failed - $@", $r) if $@; - return $self->{account}; -} - -# Create a new account using data in %info. -# Optionally pass $tos_url to agree to the given Terms of Service -# POST to newAccount endpoint -# Expects a '201 Created' reply -# Saves and returns the account data -sub new_account { - my ($self, $tos_url, %info) = @_; - my $url = $self->_method('newAccount'); - - if ($tos_url) { - $self->{tos} = $tos_url; - $info{termsOfServiceAgreed} = JSON::true; - } - - return $self->__new_account(201, $url, 1, %info); -} - -# Update existing account with new %info -# POST to account URL -# Expects a '200 OK' reply -# Saves and returns updated account data -sub update_account { - my ($self, %info) = @_; - my (undef, $url) = $self->_account; - - return $self->__new_account(200, $url, 0, %info); -} - -# Retrieves existing account information -# POST to account URL with empty body! -# Expects a '200 OK' reply -# Saves and returns updated account data -sub get_account { - my ($self) = @_; - return $self->update_account(); -} - -# Start a new order for one or more domains -# POST to newOrder endpoint -# Expects a '201 Created' reply -# returns order URL and parsed order object, including authorization and finalize URLs -sub new_order { - my ($self, $domains) = @_; - - my $url = $self->_method('newOrder'); - my $req = { - identifiers => [ map { { type => 'dns', value => $_ } } @$domains ], - }; - - my $r = $self->do(POST => $url, $req); - my ($order_url, $order); - eval { - $order_url = $r->header('Location') - or die "did not receive an order URL\n"; - $order = __get_result($r, 201) - }; - $self->fatal("POST to '$url' failed - $@", $r) if $@; - return ($order_url, $order); -} - -# Finalize order after all challenges have been validated -# POST to order's finalize URL -# Expects a '200 OK' reply -# returns (potentially updated) order object -sub finalize_order { - my ($self, $order, $csr) = @_; - - my $req = { - csr => encode($csr), - }; - my $r = $self->do(POST => $order->{finalize}, $req); - my $return = eval { __get_result($r, 200); }; - $self->fatal("POST to '$order->{finalize}' failed - $@", $r) if $@; - return $return; -} - -# Get order status -# GET-as-POST to order URL -# Expects a '200 OK' reply -# returns order object -sub get_order { - my ($self, $order_url) = @_; - my $r = $self->do(POST => $order_url, ''); - my $return = eval { __get_result($r, 200); }; - $self->fatal("POST of '$order_url' failed - $@", $r) if $@; - return $return; -} - -# Gets authorization object -# GET-as-POST to authorization URL -# Expects a '200 OK' reply -# returns authorization object, including challenges array -sub get_authorization { - my ($self, $auth_url) = @_; - - my $r = $self->do(POST => $auth_url, ''); - my $return = eval { __get_result($r, 200); }; - $self->fatal("POST of '$auth_url' failed - $@", $r) if $@; - return $return; -} - -# Deactivates existing authorization -# POST to authorization URL -# Expects a '200 OK' reply -# returns updated authorization object -sub deactivate_authorization { - my ($self, $auth_url) = @_; - - my $req = { - status => 'deactivated', - }; - my $r = $self->do(POST => $auth_url, $req); - my $return = eval { __get_result($r, 200); }; - $self->fatal("POST to '$auth_url' failed - $@", $r) if $@; - return $return; -} - -# Get certificate -# GET-as-POST to order's certificate URL -# Expects a '200 OK' reply -# returns certificate chain in PEM format -sub get_certificate { - my ($self, $order) = @_; - - $self->fatal("no certificate URL available (yet?)", $order) - if !$order->{certificate}; - - my $r = $self->do(POST => $order->{certificate}, ''); - my $return = eval { __get_result($r, 200, 1); }; - $self->fatal("POST of '$order->{certificate}' failed - $@", $r) if $@; - return $return; -} - -# Revoke given certificate -# POST to revokeCert endpoint -# currently only supports revokation with account key -# $certificate can either be PEM or DER encoded -# Expects a '200 OK' reply -sub revoke_certificate { - my ($self, $certificate, $reason) = @_; - - my $url = $self->_method('revokeCert'); - - if ($certificate =~ /^-----BEGIN CERTIFICATE-----/) { - $certificate = PVE::Certificate::pem_to_der($certificate); - } - - my $req = { - certificate => encode($certificate), - reason => $reason // 0, - }; - # TODO: set use_jwk if revoking with certificate key - my $r = $self->do(POST => $url, $req); - eval { - die "unexpected code $r->code\n" if $r->code != 200; - }; - $self->fatal("POST to '$url' failed - $@", $r) if $@; -} - -# Request validation of challenge -# POST to challenge URL -# call after validation has been setup -# returns (potentially updated) challenge object -sub request_challenge_validation { - my ($self, $url, $key_authorization) = @_; - - my $req = { keyAuthorization => $key_authorization }; - - my $r = $self->do(POST => $url, $req); - my $return = eval { __get_result($r, 200); }; - $self->fatal("POST to '$url' failed - $@", $r) if $@; - return $return; -} - -# actually 'do' a $method request on $url -# $data: input for JWS, optional -# $use_jwk: use JWK instead of KID in JWD (see sub jws) -sub do { - my ($self, $method, $url, $data, $use_jwk) = @_; - - $self->fatal("Error: can't $method to empty URL") if !$url || $url eq ''; - - my $headers = HTTP::Headers->new(); - $headers->header('Content-Type' => 'application/jose+json'); - my $content = defined($data) ? $self->jws($use_jwk, $data, $url) : undef; - my $request; - if (defined($content)) { - $content = tojs($content); - $request = HTTP::Request->new($method, $url, $headers, $content); - } else { - $request = HTTP::Request->new($method, $url, $headers); - } - my $res = $self->{ua}->request($request); - if (!$res->is_success) { - # check for nonce rejection - if ($res->code == 400 && $res->decoded_content) { - my $parsed_content = fromjs($res->decoded_content); - if ($parsed_content->{type} eq 'urn:ietf:params:acme:error:badNonce') { - warn("bad Nonce, retrying\n"); - $self->{nonce} = $res->header('Replay-Nonce'); - return $self->do($method, $url, $data, $use_jwk); - } - } - $self->fatal("Error: $method to $url\n".$res->decoded_content, $res); - } - if (my $nonce = $res->header('Replay-Nonce')) { - $self->{nonce} = $nonce; - } - return $res; -} - -1; diff --git a/src/PVE/ACME/Challenge.pm b/src/PVE/ACME/Challenge.pm deleted file mode 100644 index 40d32b6..0000000 --- a/src/PVE/ACME/Challenge.pm +++ /dev/null @@ -1,22 +0,0 @@ -package PVE::ACME::Challenge; - -use strict; -use warnings; - -sub supported_challenge_types { - return {}; -} - -sub setup { - my ($class, $acme, $authorization) = @_; - - die "implement me\n"; -} - -sub teardown { - my ($self) = @_; - - die "implement me\n"; -} - -1; diff --git a/src/PVE/ACME/StandAlone.pm b/src/PVE/ACME/StandAlone.pm deleted file mode 100644 index f48d638..0000000 --- a/src/PVE/ACME/StandAlone.pm +++ /dev/null @@ -1,71 +0,0 @@ -package PVE::ACME::StandAlone; - -use strict; -use warnings; - -use HTTP::Daemon; -use HTTP::Response; - -use base qw(PVE::ACME::Challenge); - -sub supported_challenge_types { - return { 'http-01' => 1 }; -} - -sub setup { - my ($class, $acme, $authorization) = @_; - - my $challenges = $authorization->{challenges}; - die "no challenges defined in authorization\n" if !$challenges; - - my $http_challenges = [ grep {$_->{type} eq 'http-01'} @$challenges ]; - die "no http-01 challenge defined in authorization\n" - if ! scalar $http_challenges; - - my $http_challenge = $http_challenges->[0]; - - die "no token found in http-01 challenge\n" if !$http_challenge->{token}; - - my $key_authorization = $acme->key_authorization($http_challenge->{token}); - - my $server = HTTP::Daemon->new( - LocalPort => 80, - ReuseAddr => 1, - ) or die "Failed to initialize HTTP daemon\n"; - my $pid = fork() // die "Failed to fork HTTP daemon - $!\n"; - if ($pid) { - my $self = { - server => $server, - pid => $pid, - authorization => $authorization, - key_auth => $key_authorization, - url => $http_challenge->{url}, - }; - - return bless $self, $class; - } else { - while (my $c = $server->accept()) { - while (my $r = $c->get_request()) { - if ($r->method() eq 'GET' and $r->uri->path eq "/.well-known/acme-challenge/$http_challenge->{token}") { - my $resp = HTTP::Response->new(200, 'OK', undef, $key_authorization); - $resp->request($r); - $c->send_response($resp); - } else { - $c->send_error(404, 'Not found.') - } - } - $c->close(); - $c = undef; - } - } -} - -sub teardown { - my ($self) = @_; - - eval { $self->{server}->close() }; - kill('KILL', $self->{pid}); - waitpid($self->{pid}, 0); -} - -1;