From 30faa3e60ea79b6ed7710bc9aaef678c63a87e41 Mon Sep 17 00:00:00 2001
From: Přemysl Janouch 
Date: Tue, 16 May 2017 17:01:24 +0200
Subject: Initial commit
---
 acme-tinier.pl | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 159 insertions(+)
 create mode 100755 acme-tinier.pl
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 = ;
+	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";
-- 
cgit v1.2.3-70-g09d2