#!/usr/bin/perl -T

#----------------------------------------------------------------------
# Let's Encrypt ACME client
# Copyright © 2015 Guilhem Moulin <guilhem@fripost.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#----------------------------------------------------------------------

use strict;
use warnings;

use LWP::UserAgent ();
use Crypt::OpenSSL::RSA ();
use Crypt::OpenSSL::Bignum ();
use MIME::Base64 qw/encode_base64 encode_base64url/;
use JSON ();
use Digest::SHA qw/sha256 sha256_hex/;

# Clean up PATH
$ENV{PATH} = join ':', qw{/usr/bin /bin};
delete @ENV{qw/IFS CDPATH ENV BASH_ENV/};

my $COMMAND = shift @ARGV // die;
my $PUBKEY = shift @ARGV // die;
die unless grep {$COMMAND eq $_} qw/new-reg new-cert revoke-cert/;
my $TIMEOUT = 10;


# Read the public key and build the JSON Web Key (RFC 7517)
my $JWK = do {
    open my $fh, '<', $PUBKEY or die "Can't open $PUBKEY: $!";
    my $str = do { local $/ = undef; <$fh> };
    my $pubkey = Crypt::OpenSSL::RSA->new_public_key($str) or die;
    close $fh;

    my ($n, $e) = $pubkey->get_key_parameters();
    $_ = encode_base64url($_->to_bin()) foreach ($n, $e);

    { kty => 'RSA', n => $n, e => $e }
};
my $JSON = JSON::->new->utf8->canonical(); # breaks hashes otherwise
my $JWK_dgst64 = encode_base64url(sha256($JSON->encode($JWK)));
my $NONCE;


# Send an HTTP request to the ACME server
my $UA = LWP::UserAgent::->new( ssl_opts => {
    verify_hostname => 1,
    SSL_version => 'SSLv23:!TLSv1_1:!TLSv1:!SSLv3:!SSLv2',
    SSL_cipher_list => 'EECDH+AESGCM:!MEDIUM:!LOW:!EXP:!aNULL:!eNULL'
});
sub request($$;$) {
    my ($method, $uri, $json) = @_;
    print STDERR ">>> $method $uri <<<\n" if $ENV{DEBUG};

    my $req = HTTP::Request::->new($method => $uri) or die "Can't $method $uri";
    if (defined $json) {
        $req->header('Content-Type' => 'application/json');
        $req->content($JSON->encode($json));
    }
    my $r = $UA->request($req) or die "Can't $method $uri";
    print STDERR ">>> ", $r->status_line, "\n", $r->headers->as_string, "\n" if $ENV{DEBUG};
    $NONCE = $r->header('Replay-Nonce') // die;
    my $t = $r->header('Content-Type');

    my $content = $r->decoded_content();
    if (defined $t and $t =~ /\Aapplication\/(?:[a-z]+\+)?json\z/) {
        $content = $JSON->decode($content);
        print STDERR $JSON->pretty->encode($content), "\n" if $ENV{DEBUG};
    }
    elsif (defined $t and $t eq 'application/pkix-cert') {
        print STDERR encode_base64($content), "\n" if $ENV{DEBUG};
    }
    else {
        print STDERR $content, "\n" if $ENV{DEBUG};
    }
    unless ($r->is_success) {
        my $msg = $r->status_line;
        $msg .= " (".$content->{detail}.")" if ref $content and defined $content->{detail};
        die $msg, "\n";
    }

    return $content;
}


# ACME client
# https://tools.ietf.org/html/draft-ietf-acme-acme-01
sub acme($$) {
    my ($uri, $h) = @_;

    # Produce the JSON Web Signature: RFC 7515 section 5
    my $payload = encode_base64url($JSON->encode($h));
    my %header = ( alg => 'RS256', jwk => $JWK );
    my $protected = encode_base64url($JSON->encode({ %header, nonce => $NONCE }));
    my $data = $protected .'.'. $payload;
    print STDERR "Requesting a SHA-256 signature for ", $data, "\n" if $ENV{DEBUG};
    STDOUT->printflush($data, "\n");

    # Ask for an (hex) sig
    my $sig = do { local $_ = <STDIN>; chomp; $_ };
    $sig = encode_base64url(pack('H*', $sig));
    print STDERR "Got SHA-256 signature ", $sig, "\n" if $ENV{DEBUG};

    # Flattened JSON Serialization, RFC 7515 section 7.2.2
    request(POST => $uri, {
        payload => $payload,
        protected => $protected,
        header => \%header,
        signature => $sig
    });
}


# Query the root ACME directory to initialize the nonce and get the resources URIs
my %RES = %{ request(GET => "https://acme-v01.api.letsencrypt.org/directory") };


if ($COMMAND eq 'new-reg') {
    print STDERR "Requesting new registration ".(@ARGV ? ("for ".join(', ', @ARGV)) : "")."\n";
    my $uri = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf";
    my $dgst = sha256_hex($UA->get($uri)->decoded_content());
    die "Error: The CA's subscriber agreement (URL $uri) has changed!\n" if
        $dgst ne '33d233c8ab558ba6c8ebc370a509acdded8b80e5d587aa5d192193f35226540f';

    acme($RES{'new-reg'}, {
        resource => 'new-reg',
        contact => [ map {"mailto:$_"} @ARGV ],
        agreement => $uri,
    });
    exit;
}


if ($COMMAND eq 'revoke-cert') {
    print STDERR "Requesting revocation for\n";
    for my $cert (@ARGV) {
        open my $fh1, '-|', qw/openssl x509 -noout -subject -serial -fingerprint -sha256/, '-in', $cert
            or die "Can't run x509(1ssl): $!";
        my ($subject, $serial, $fingerprint) = map { s/[^=]+=\s*//; chomp; $_ } <$fh1>;
        close $fh1;

        print STDERR "\n\tSubject:             $subject\n",
                       "\tSerial:              $serial\n",
                       "\tSHA-256 fingerprint: $fingerprint\n";

        open my $fh2, '-|', qw/openssl x509 -outform DER/, '-in', $cert or die "Can't run x509(1ssl): $!";
        my $der = do { local $/ = undef; <$fh2> };
        close $fh2;

        acme($RES{'revoke-cert'}, {
            resource => 'revoke-cert',
            certificate => encode_base64url($der)
        });
    }
    exit;
}


# $COMMAND eq 'new-cert'
my ($CSR, $CHALLENGE_DIR, $X509) = @ARGV;
$CHALLENGE_DIR = $CHALLENGE_DIR =~ /\A(\/\p{Print}+)\z/ ? $1 :
    die "Error: Challenge directory is not absolute: $CHALLENGE_DIR";

# Parse the Certificate Signing Request
# XXX use a library instead, perhaps Crypt::OpenSSL::PKCS10
my @domains = do {
    my @req = (qw/openssl req -noout/, '-in', $CSR);

    my $RE_label = qr/[0-9a-z](?:[0-9a-z\x2D]{0,61}[0-9a-z])?/aai;
    my $RE_domain = qr/$RE_label(?:\.$RE_label)+/;
    my %domains;

    open my $fh1, '-|', @req, '-subject' or die "Can't run req(1ssl): $!";
    my $subject = <$fh1>;
    close $fh1;
    $domains{$1} = 1 if $subject =~ /\Asubject=.*\/CN=($RE_domain)\n\z/o;

    open my $fh2, '-|', @req, '-text', '-reqopt', 'no_header,no_version,no_subject,no_pubkey,no_sigdump'
        or die "Can't run req(1ssl): $!";
    while (<$fh2>) {
        /\A\s+X509v3 Subject Alternative Name:/ or next;
        my $san = <$fh2>;
        foreach (split /,/, $san) {
            chomp;
            s/\A\s*//;
            next unless s/\ADNS://;
            if (/\A$RE_domain\z/o) {
                $domains{$_} = 1;
            }
            else {
                warn "WARNING: Ignoring invalid domain $_\n";
            }
        }
        last;
    }
    close $fh2;

    keys %domains;
};
print STDERR "Found domain(s): ".join(", ", @domains), "\n" if $ENV{DEBUG};


# Process DNS Authorizations
foreach my $domain (@domains) {
    print STDERR "Processing new DNS authz for $domain\n" if $ENV{DEBUG};
    my $challenges = acme($RES{'new-authz'}, {
        resource => 'new-authz',
        identifier => { type => 'dns', value => $domain }
    });
    die "No challenge in server response" unless defined $challenges->{challenges};
    my ($challenge) = grep {$_->{type} eq 'http-01'} @{$challenges->{challenges}};
    my $keyAuthorization = $challenge->{token}.'.'.$JWK_dgst64;

    # serve $keyAuthorization at http://$domain/.well-known/acme-challenge/$challenge->{token}
    my $filename = $CHALLENGE_DIR.'/'.$challenge->{token};
    if (-e $filename) {
        warn "WARNING: File exists: $filename\n";
    }
    else {
        open my $fh, '>', $filename or die "Can't open $filename: $!";
        print $fh $keyAuthorization;
        close $fh;
    }

    acme($challenge->{uri}, {
        resource => 'challenge',
        keyAuthorization => $keyAuthorization
    });

    for (my $i=0;; $i++) {
        my $status = request('GET' => $challenge->{uri})->{status} // 'pending';
        die "Error: Invalid challenge for $domain\n" if $status eq 'invalid';
        last if $status eq 'valid';
        die "Timeout exceeded while waiting for challenge to pass ($domain)\n" if $i >= $TIMEOUT;
        sleep 1;
    }
}


do {
    print STDERR "Processing new CSR\n" if $ENV{DEBUG};
    open my $fh1, '-|', qw/openssl req -outform DER/, '-in', $CSR or die "Can't run req(1ssl): $!";
    my $req = do { local $/ = undef; <$fh1> };
    close $fh1;

    # The server also gives the cert URI in its 'Location' header in
    # https://acme-v01.api.letsencrypt.org/acme/cert/$serial
    my $x509 = acme($RES{'new-cert'}, {
        resource => 'new-cert',
        csr => encode_base64url($req)
    });

    open my $fh2, '|-', qw/openssl x509 -inform DER/, '-out', $X509 or die "Can't run x509(1ssl): $!";
    print $fh2 $x509;
    close $fh2;
};