aboutsummaryrefslogtreecommitdiff
path: root/acme-tinier.pl
blob: 1c32124f82d71097f3c4f406d96ec70f44c8525b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
#!/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.
#
# TODO: eventually the ACME protocol will stabilize:
#   https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md
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 }
my $key_info = `openssl rsa -in '$account_key' -noout -text`;
die 'cannot process account key' if $?;

my ($pub_hex, $pub_exp) =
	$key_info =~ /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)->canonical(1);
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 (quite silly)
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+?): (.*)\r\n/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 anymore, 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
my ($code, $result, $headers) = get "$ca/terms";
($code, $result) = send_signed("$ca/acme/new-reg", {
	resource => 'new-reg',
	agreement => ($code == 302 && exists $headers->{Location})
		? $headers->{Location}
		: 'https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf'
});
die "cannot register: $code\n$result" if $code != 201 && $code != 409;

# Check if the file is really there, submit an HTTP challenge and wait
sub verify_http {
	my ($checked_url, $key_auth, $challenge_uri) = @_;
	my ($code, $result) = get $checked_url;
	die "checking $checked_url failed: $code" if $code != 200;
	die 'challenge contents differ' if $result ne $key_auth;

	($code, $result) = send_signed($challenge_uri, {
		resource => 'challenge', keyAuthorization => $key_auth
	});
	die "challenge submission failed: $code\n$result" if $code != 202;

	while (1) {
		($code, $result) = get $challenge_uri;
		die "challenge verification failed: $code\n$result" if $code >= 400;
		my $status = $json->decode($result);
		if ($status->{status} eq 'valid') {
			last;
		} elsif ($status->{status} eq 'pending') {
			sleep 1;
		} else {
			die "challenge verification failed: $result";
		}
	}
}

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\n$result" if $code != 201;

	my ($challenge) = grep { $_->{type} eq 'http-01' }
		@{$json->decode($result)->{challenges}};
	my $token = $challenge->{token} =~ s/[^A-Za-z0-9_-]/_/gr;
	my $key_auth = "$token.$thumbprint";
	my $known_path = "$acme_dir/$token";

	open(my $fh, '>', $known_path) or die "cannot write to $known_path: $!";
	print $fh $key_auth;
	close $fh;

	eval { verify_http("http://$domain/.well-known/acme-challenge/$token",
		$key_auth, $challenge->{uri}) };

	unlink $known_path;
	die "$domain: $@" if $@;
}

# Get the new certificate and convert it to 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\n$result" if $code != 201;

my $pem = join("\n", unpack '(A64)*', encode_base64($result, ''));
print "-----BEGIN CERTIFICATE-----\n$pem\n-----END CERTIFICATE-----\n";