[pve-devel] [RFC common 1/4] acme: add ACME library

Fabian Grünbichler f.gruenbichler at proxmox.com
Wed Apr 11 10:08:43 CEST 2018


this implements those parts of draft-ietf-acme-acme-09 which are needed
to use Let's Encrypt's v2 API.

(based on an internal implement for the Let's Encrypt v1 API)
Co-Authored-By: Wolfgang Bumiller <w.bumiller at proxmox.com>
Signed-off-by: Fabian Grünbichler <f.gruenbichler at proxmox.com>
---
 src/PVE/ACME.pm | 619 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 619 insertions(+)
 create mode 100644 src/PVE/ACME.pm

diff --git a/src/PVE/ACME.pm b/src/PVE/ACME.pm
new file mode 100644
index 0000000..7e6b861
--- /dev/null
+++ b/src/PVE/ACME.pm
@@ -0,0 +1,619 @@
+package PVE::ACME;
+
+use strict;
+use warnings;
+
+use POSIX;
+
+use Data::Dumper;
+use Date::Parse;
+use MIME::Base64 qw(encode_base64url decode_base64);
+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::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) my ($csr, $key) = $acme->new_csr($order, 'dir/for/csr/and/key');
+# 6) $acme->finalize_order($order, file_get_contents($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 $data = fromjs(file_get_contents($self->{path}));
+    $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 = encode(tojs($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."'"
+	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 = eval { fromjs($r->decoded_content); };
+    $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";
+	$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 to order URL
+# Expects a '200 OK' reply
+# returns order object
+sub get_order {
+    my ($self, $order_url) = @_;
+    my $r = $self->do(GET => $order_url);
+    my $return = eval { __get_result($r, 200); };
+    $self->fatal("GET of '$order_url' failed - $@", $r) if $@;
+    return $return;
+}
+
+# Gets authorization object
+# GET 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(GET => $auth_url);
+    my $return = eval { __get_result($r, 200); };
+    $self->fatal("GET 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 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(GET => $order->{certificate});
+    my $return = eval { __get_result($r, 200, 1); };
+    $self->fatal("GET 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-----/) {
+	eval {
+	    # convert from PEM (chain) to DER (cert)
+	    $certificate =~ s/^-----BEGIN CERTIFICATE-----$//mg;
+	    $certificate =~ s/-----END CERTIFICATE-----.*//sg;
+	    $certificate = decode_base64($certificate);
+	};
+	$self->fatal("conversion from PEM to DER failed - $@") if $@;
+    }
+
+    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" 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;
+}
+
+# Create a CSR and certificate key for a given order
+# returns path to CSR file or path to CSR and key files
+sub new_csr {
+    my ($self, $order, %attr) = @_;
+    my $bits = delete($attr{bits}) // 4096;
+    my $path = delete($attr{path}) // '.';
+    my $keyfile = "$path/csr.key";
+    my $csrfile = "$path/csr.csr";
+
+    my $key = eval { Crypt::OpenSSL::RSA->generate_key($bits); };
+    $self->fatal("Generating key for CSR failed - $@") if $@;
+
+    eval { file_set_contents($keyfile, $key->get_private_key_string()); };
+    $self->fatal("Storing key in '$keyfile' failed - $@", $key) if $@;
+
+    my $san = [ map { $_->{value} } grep { $_->{type} eq 'dns' } @{$order->{identifiers}} ];
+    $self->fatal("No DNS identifiers found in order", $order) if !scalar @$san;
+
+    my $domain = @$san[0];
+
+    my $extension_list = '';
+    my $v3 = '';
+    my $alt_names = '';
+    if ($san && @$san) {
+	$extension_list .= ' v3_req';
+	$v3 .= "subjectAltName = \@alt_names\n";
+
+	$alt_names = "[alt_names]\n";
+	my $i=0;
+	foreach my $d (@$san) {
+	    $alt_names .= "DNS.${i} = $d\n";
+	    $i++;
+	}
+    }
+
+    $extension_list = "req_extensions = $extension_list\n"
+	if length($extension_list);
+    $v3 = "[ v3_req ]\n$v3"
+	if length($v3);
+
+    my $csrconfig = <<"EOCSR";
+[ req ]
+distinguished_name = req_distinguished_name
+prompt             = no
+output_password    =
+$extension_list
+
+$v3
+
+$alt_names
+
+[ req_distinguished_name ]
+CN                 = $domain
+EOCSR
+    for (qw(C ST L O OU)) {
+	if (defined(my $v = $attr{$_})) {
+	    $csrconfig .= "$_ = $v\n";
+	}
+    }
+
+    my @cmd = qw(openssl req -batch -new -outform DER -sha256 -config - );
+    push @cmd, '-key', $keyfile;
+    push @cmd, '-out', $csrfile;
+    pipe(my $cmdin, my $cmdout) or die "pipe: $!\n";
+    my $pid = fork // die "fork: $!\n";
+    if (!$pid) {
+	if (fileno($cmdin) != 0) {
+	    POSIX::dup2(fileno($cmdin), 0);
+	    close($cmdin);
+	}
+	close($cmdout);
+	exec({'openssl'} @cmd)
+	    or POSIX::_exit(-1);
+    }
+    close($cmdin);
+    print {$cmdout} $csrconfig;
+    close($cmdout);
+    if (waitpid($pid, 0) != $pid) {
+	warn "interrupted\n";
+	kill(9, $pid);
+	1 while waitpid($pid, 0) != $pid;
+	die "openssl command failed\n";
+    }
+    return wantarray ? ($csrfile, $keyfile) : $csrfile;
+}
+
+# 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) = @_;
+    my $headers = HTTP::Headers->new();
+    $headers->header('Content-Type' => 'application/jose+json');
+    my $content = $self->jws($use_jwk, $data, $url) if defined($data);
+    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");
+		$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;
-- 
2.14.2





More information about the pve-devel mailing list