diff options
-rwxr-xr-x | acme-tinier.pl | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/acme-tinier.pl b/acme-tinier.pl new file mode 100755 index 0000000..c0c54f5 --- /dev/null +++ b/acme-tinier.pl @@ -0,0 +1,159 @@ +#!/usr/bin/env perl +# This is a simplified rewrite of acme-tiny in Perl, since Python 3 is 125 MiB +# but Perl is everywhere and JSON::PP mostly in default installations. +# Depends on curl and openssl. +use strict; +use warnings; +use MIME::Base64 qw(encode_base64 encode_base64url); +use JSON::PP; +use Digest::SHA qw(sha256); +use IPC::Open2; + +my $ca = 'https://acme-staging.api.letsencrypt.org'; +# 'https://acme-v01.api.letsencrypt.org' +my $account_key = 'account.key'; +my $csr_file = shift; +my $public_dir = '/srv/http/htdocs/acme-challenge'; + +# Prepare some values derived from account key for the ACME protocol +sub b64 { encode_base64url(shift, '') =~ s/=//gr } +$_ = `openssl rsa -in '$account_key' -noout -text`; +die 'cannot process account key' if $?; + +my ($pub_hex, $pub_exp) = + /modulus:\n\s+00:([a-f\d:\s]+?)\npublicExponent: (\d+)/m; +$pub_exp = sprintf("%x", $pub_exp); +$pub_exp = "0$pub_exp" if length($pub_exp) % 2; + +my $header = { alg => 'RS256', jwk => { kty => 'RSA', + e => b64(pack 'H*', $pub_exp), + n => b64(pack 'H*', $pub_hex =~ s/\s|://gr) +}}; + +my $json = JSON::PP->new->pretty(0); +my $thumbprint = b64(sha256($json->encode($header->{jwk}))); + +# Pipe data through an external program, keeping status in $? +sub communicate { + my $data = pop; + my $pid = open2(\*Reader, \*Writer, @_); + print Writer $data if defined($data); + close Writer; + local $/ = undef; + my $resp = <Reader>; + waitpid $pid, 0; + return $resp; +} + +# Use cURL to download a file over HTTPS but parse it ourselves +sub get { + my ($url, $data) = @_; + my @args = ('curl', '-sS', '-D-', '-H', 'Expect:'); + push @args, ('-X', 'POST', '--data-binary', '@-') if defined($data); + my $resp = communicate(@args, $url, $data); + die 'cannot download' if $? >> 8; + my ($code, $headers, $body) = + $resp =~ m#\AHTTP/\d\.\d (\d+) .*?\r\n(.*?)\r\n\r\n(.*)#sm; + return ($code, $body, { $headers =~ /(\S+?): (.*)$/mg }) +} + +# Make a signed request to an ACME endpoint +sub send_signed { + my ($url, $payload) = @_; + my $protected = { nonce => `curl -sSfI '$ca/directory'` + =~ /Replay-Nonce: (\S+)/i, %$header }; + die 'cannot retrieve nonce' if $?; + + my $b64payload = b64 $json->encode($payload); + my $b64protected = b64 $json->encode($protected); + my $out = communicate('openssl', 'dgst', '-sha256', '-sign', $account_key, + "$b64protected.$b64payload"); + die 'cannot sign request' if $? >> 8; + return get $url, $json->encode({ + header => $header, protected => $b64protected, + payload => $b64payload, signature => b64 $out + }) +} + +# Find all domains specified in the certificate request +my $csr = `openssl req -in '$csr_file' -noout -text`; +die 'cannot parse CSR' if $?; + +my @domains; +push @domains, $1 if $csr =~ /Subject:.*? CN *= *([^\s,;\/]+)/; +# FIXME: this may not parse correctly either, try it out +push @domains, map { substr $_, 4 } grep { /^DNS:/ } split(/, /) + for $csr =~ /X509v3 Subject Alternative Name: \n +([^\n]+)\n/g; + +# Get certificate domains and expiration +# FIXME: don't hardcode the agreement, that may stop working +my ($code, $result) = send_signed("$ca/acme/new-reg", { + resource => 'new-reg', + agreement => 'https://letsencrypt.org/documents/' + . 'LE-SA-v1.1.1-August-1-2016.pdf' +}); +die "cannot register: $code" if $code != 201 && $code != 409; + +# Run each domain through the ACME challenge +for my $domain (@domains) { + my ($code, $result) = send_signed("$ca/acme/new-authz", { + resource => 'new-authz', + identifier => { type => 'dns', value => $domain } + }); + die "cannot request challenge: $code" if $code != 201; + + my ($challenge) = grep { $_->{type} eq 'http-01' } + @{$json->decode($result)->{challenges}}; + my $token = $challenge->{token} =~ s/[^A-Za-z0-9_-]/_/r; + my $key_auth = "$token.$thumbprint"; + my $known_path = "$public_dir/$token"; + + # Make the challenge file and check that it can be retrieved + open(my $fh, '>', $known_path) or die "cannot write $known_path: $!"; + print $fh $key_auth; + close $fh; + +eval { + my $url = "http://$domain/.well-known/acme-challenge/$token"; + my ($code, $result) = get $url; + die "checking challenge failed: $code" if $code != 200; + die 'challenge contents differ' if $result ne $key_auth; + + # Submit the challenge and wait for the verification to finish + ($code, $result) = send_signed($challenge->{uri}, { + resource => 'challenge', + keyAuthorization => $key_auth + }); + die "checking challenge failed: $code" if $code != 202; + + while (1) { + ($code, $result) = get $challenge->{uri}; + die "verifying challenge failed: $code" if $code >= 400; + my $status = $json->decode($result); + if ($status->{status} eq 'valid') { + last; + } elsif ($status->{status} eq 'pending') { + sleep 1; + } else { + die "verifying challenge failed: $status"; + } + } +}; + + # Make sure our file gets deleted and rethrow any error + unlink $known_path; + die $@ if $@; +} + +# Get the new certificate and print it in the PEM format +my $der = `openssl req -in '$csr_file' -outform DER`; +die 'cannot convert CSR' if $?; +($code, $result) = send_signed("$ca/acme/new-cert", { + resource => 'new-cert', + csr => b64 $der +}); +die "cannot sign certificate: $code" if $code != 201; + +print "-----BEGIN CERTIFICATE-----\n" + . join("\n", unpack '(A64)*', encode_base64($result, '')) + . "\n-----END CERTIFICATE-----\n"; |