[pve-devel] [Patch V3 common] Move the code to proxmox-acme and add a dependency on it.
Wolfgang Link
w.link at proxmox.com
Thu Apr 16 07:18:20 CEST 2020
Signed-off-by: Wolfgang Link <w.link at proxmox.com>
---
debian/control | 1 +
src/PVE/ACME.pm | 533 -------------------------------------
src/PVE/ACME/Challenge.pm | 22 --
src/PVE/ACME/StandAlone.pm | 71 -----
4 files changed, 1 insertion(+), 626 deletions(-)
delete mode 100644 src/PVE/ACME.pm
delete mode 100644 src/PVE/ACME/Challenge.pm
delete mode 100644 src/PVE/ACME/StandAlone.pm
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 at 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;
--
2.20.1
More information about the pve-devel
mailing list