#!/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; # https://acme-staging.api.letsencrypt.org # https://acme-v01.api.letsencrypt.org my $ca = $ENV{ACME_CA} || die 'ACME_CA not set'; my $account_key = $ENV{ACCOUNT_KEY} || die 'ACCOUNT_KEY not set'; my $csr_file = shift || die 'no file was given'; my $acme_dir = $ENV{ACME_DIR} || die 'ACME_DIR not set'; # 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 = ; 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 = "$acme_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";