#!/usr/bin/perl
#
# Create key, csr, and sign the csr to produce a cert

use AppConfig qw(:argcount :expand);
use Carp;
use File::Temp qw/ tempfile /;
use Getopt::Long;
use IPC::Run qw ( run timeout );
use Pod::Usage;
use strict;
use Term::ReadPassword;

my $opt_cacert;
my $opt_caconf;
my $opt_capass;
my $opt_cakey;
my $opt_certpass;
my $opt_certroot;
my $opt_certtype;
my $opt_conf;
my $opt_debug;
my $opt_example;
my $opt_find;
my $opt_help;
my $opt_manual;
my $opt_revoke;
my $opt_ssltype;

my $CONF;

##############################################################################
# Helper routines
##############################################################################

#------------------------------------------------------------------------
# Read configuration file.  The configuration file is used to set the
# command line switch variables, i.e. opt_*, for selected values.

sub read_config {

    my ($filename) = @_;
    if ($opt_debug) {
        prt("DEBUG:Configuration file $filename\n");
    }

    $CONF = AppConfig->new({});
    $CONF->define('cacert', { ARGCOUNT => ARGCOUNT_ONE });
    $CONF->define('cakey',  { ARGCOUNT => ARGCOUNT_ONE });
    $CONF->define('capass', { ARGCOUNT => ARGCOUNT_ONE });
    $CONF->define(
        'caconf',
        {
            DEFAULT  => '/etc/caroot-intermediate/openssl.conf',
            ARGCOUNT => ARGCOUNT_ONE
        }
    );
    $CONF->define('certpass', { ARGCOUNT => ARGCOUNT_ONE });
    $CONF->define('certroot', { ARGCOUNT => ARGCOUNT_ONE });
    $CONF->define(
        'certtype',
        {
            DEFAULT  => 'user',
            ARGCOUNT => ARGCOUNT_ONE
        }
    );
    $CONF->define('debug', { ARGCOUNT => ARGCOUNT_NONE });
    $CONF->define(
        'ssltype',
        {
            DEFAULT  => 'openssl',
            ARGCOUNT => ARGCOUNT_ONE
        }
    );

    if (-e $filename) {
        if ($opt_debug) {
            prt("DEBUG:Reading configuration file $filename\n");
        }
        $CONF->file($filename) or die "ERROR: problem reading $filename";
    }

    if ($opt_cacert)   { $CONF->cacert($opt_cacert); }
    if ($opt_cakey)    { $CONF->cakey($opt_cakey); }
    if ($opt_caconf)   { $CONF->caconf($opt_caconf); }
    if ($opt_certpass) { $CONF->certpass($opt_certpass); }
    if ($opt_certroot) { $CONF->certroot($opt_certroot); }
    if ($opt_certtype) { $CONF->certtype($opt_certtype); }
    if ($opt_debug)    { $CONF->debug(1); }
    if ($opt_ssltype)  { $CONF->ssltype($opt_ssltype); }

    if (!$CONF->capass) {
        my $pw = read_password('CA password:');
        if (!$pw) {
            prt("ERROR: CA Password is required.");
            pod2usage(-verbose => 0);
        }
        $CONF->capass($pw);
    }

    return;
}

#------------------------------------------------------------------------
# Print an example configuration file and exit

sub conf_example {

    prt("# make-signed-cert.conf\n");
    prt("#\n");
    prt("# Example configuration file for make-signed-cert.\n");
    prt("#\n");
    prt("# The SSL libraries to use to generate the certificate.\n");
    prt("ssltype = openssl\n");
    prt("#\n");
    prt("# The type of cert to generate user or server.\n");
    prt("certtype = user\n");
    prt("#\n");
    prt("# The password that protects the CA certificate\n");
    prt("capass = somepassword\n");
    prt("#\n");
    prt("# The root path to the CA certifcate configuration.\n");
    prt("# Used by openssl only.\n");
    prt("caconf = /etc/caroot-intermediate/openssl.conf\n");
    prt("#\n");
    prt("# The path to the CA key.  Used by gnutls only.\n");
    prt("#cakey = /etc/caroot-intermediate/private/ca.key\n");
    prt("#\n");
    prt("# The path to the CA certifcate. Used by gnutls only.\n");
    prt("caconf = /etc/caroot-intermediate/certs/ca.pem\n");
    prt("#\n");
    prt("# The password for the certificate.  Not used for gnutls \n");
    prt("# certificates.  Optional for openssl certificates\n");
    prt("#certpass = somepassword\n");
    prt("#\n");
    prt("# The root directory that holds the certificate and the private\n");
    prt("# key.  Underneath the certroot directory the directories certs\n");
    prt("# and private must already exist.  This property is optional and\n");
    prt("# if not specified then all files are written to the current\n");
    prt("# directory\n");
    prt("#certroot = /etc/ssl\n");
    prt("#\n");
    prt("# Print debugging output.\n");
    prt("#debug = 1\n");
    return;
}

#------------------------------------------------------------------------
# run a shell command line

sub run_cmd {
    my @cmd = @_;

    my $in;
    my $out;
    my $err;
    prt("Executing: " . join(' ', @cmd) . "\n");
    eval { run(\@cmd, \$in, \$out, \$err, timeout(30)); };
    if ($@) {
        if ($err) {
            $err .= "\n";
        }
        $err .= 'ERROR executing:' . join(q{ }, @cmd) . "\n";
        $err .= $@;
        croak "$err\n";
    }
    if (eval { $CONF->debug } && $CONF->debug) {
        if ($out) {
            prt("$out\n");
        }
        if ($err) {
            prt("INFO: $err\n");
        }
    }
    return $out;
}

#------------------------------------------------------------------------
# print and die if there are errors.

sub prt {
    my ($txt) = @_;
    print {*STDOUT} $txt or croak "Problem writing to STDOUT";
    return;
}

#------------------------------------------------------------------------
# debugging output

sub dbg {
    my ($txt) = @_;
    prt("DEBUG: $txt\n");
    return;
}

#------------------------------------------------------------------------
# Write a password alone to a temporary file that can be passed
# to openssl.  It is the caller's responsibility to delete the
# file when it is no longer useful.

sub save_tmp_password {
    my ($pass) = @_;
    my ($ph, $tmp_file) = tempfile();
    print $ph "${pass}\n"
      or croak "ERROR: problem writing to $tmp_file\n";
    close $ph
      or croak "ERROR: problem closing $tmp_file\n";
    return $tmp_file;
}

#------------------------------------------------------------------------
# Write the passwords to files
sub save_password {
    my ($key, $pass, $pass_file) = @_;

    # Create a file that can be used for parameter substitutions by
    # other scripts.
    my $fh;
    open($fh, '>', $pass_file)
      or croak("ERROR: problem opening to $pass_file\n");
    print {$fh} "$key = $pass\n"
      or croak("ERROR: problem writing to $pass_file\n");
    close $fh
      or croak("ERROR: problem closing $pass_file\n");
    chmod 0600, $pass_file
      or croak("ERROR: problem setting permissions on $pass_file\n");

    # Password file for use on command lines
    my $tmp_file = save_tmp_password($pass);

    return $tmp_file;
}

#------------------------------------------------------------------------
# Check that a file exists and is not zero length, otherwise konk over
# dead.
sub check_file {
    my ($f, $result) = @_;
    if (!-e $f) {
        prt("$result\n");
        die "ERROR: $f not found\n";
    }
    if (-z $f) {
        prt("$result\n");
        die "ERROR: $f is zero length\n";
    }
    return;
}

#------------------------------------------------------------------------
# Set path names to files

sub set_paths {
    my ($this_name) = @_;
    my %paths = ();

    if ($CONF->certtype eq 'server' && $this_name !~ /[.]/xms) {
        $this_name .= '.ca-zephyr.internal';
    }
    if ($CONF->certtype eq 'user' && $this_name =~ /[.]/xms) {
        prt("WARN: user certificates should not be FQDNs\n");
    }

    $paths{'name'} = $this_name;

    $paths{cert_csr}  = $paths{name} . '.csr.pem';
    $paths{cert_key}  = $paths{name} . '.key.pem';
    $paths{cert_pass} = $paths{name} . '.password';
    $paths{cert_pem}  = $paths{name} . '.cert.pem';
    $paths{cert_tmpl} = $paths{name} . '.template';
    if (eval { $CONF->can('certroot') }) {
        $paths{cert_csr}  = $CONF->certroot . "/private/$paths{cert_csr}";
        $paths{cert_key}  = $CONF->certroot . "/private/$paths{cert_key}";
        $paths{cert_pass} = $CONF->certroot . "/private/$paths{cert_pass}";
        $paths{cert_pem}  = $CONF->certroot . "/certs/$paths{cert_pem}";
        $paths{cert_tmpl} = $CONF->certroot . "/private/$paths{cert_tmpl}";
    }

    if ($opt_debug) {
        for my $o (sort keys %paths) {
            dbg("$o = $paths{$o}");
        }
    }
    return %paths;
}

#------------------------------------------------------------------------
# Generate a cerfiticate using gnutls and sign it with the CA
# certificate.

sub gen_gnutls {
    my ($path_ref) = @_;
    my %path = %{$path_ref};

    my @cmd = ();
    my $result;

    if (eval { $CONF->can('certpass') } && $CONF->certpass) {
        prt("WARN: Passwords are not generated for gnutls certificates.\n");
    }

    prt("\n");
    prt("Generating the key for the certificate.\n");
    my @cmd = ();
    push @cmd, 'certtool';
    push @cmd, '-p';
    push @cmd, '--sec-param', 'high';
    push @cmd, '--outfile',   $path{cert_key};
    $result = run_cmd(@cmd);
    check_file($path{cert_key}, $result);
    chmod 0600, $path{cert_key}
      or croak("ERROR: problem setting permissions on $path{cert_key}\n");

    prt("\n");
    prt("Generating certificate template.\n");
    my $fh;
    open($fh, '>', $path{cert_tmpl});
    print {$fh} 'organization = "MacAllister Software"' . "\n";
    print {$fh} "cn = $path{name}\n";
    print {$fh} "tls_www_server\n";
    print {$fh} "encryption_key\n";
    print {$fh} "signing_key\n";
    print {$fh} "expiration_days = 3650\n";
    close $fh;

    prt("\n");
    prt("Generating the certificate\n");
    @cmd = (
        'certtool',              '-c',
        '--load-privkey',        $path{cert_key},
        '--load-ca-certificate', $path{cacert},
        '--load-ca-privkey',     $path{cakey},
        '--template',            $path{cert_tmpl},
        '--outfile',             $path{cert_pem}
    );
    $result = run_cmd(@cmd);
    check_file($path{cert_pem}, $result);
    # openssl specific error

    return;
}

#------------------------------------------------------------------------
# Generate a subject for the certificate

sub gen_subject {
    my ($cn) = @_;
    my $cert_subject
      = '/C=US'
      . '/ST=CA'
      . '/L=Half Moon Bay'
      . '/O=MacAllister Software'
      . '/OU=IT'
      . "/CN=$cn";
    return $cert_subject;
}

#------------------------------------------------------------------------
# Generate a cerfiticate using openssl and sign it with the CA
# certificate.

sub gen_openssl {
    my ($path_ref) = @_;
    my %path = %{$path_ref};

    my @cmd = ();
    my $result;

    if (eval { $CONF->can('certpass') } && $CONF->certpass) {
        prt("Saving key password.\n");
        save_password('CERTPASSWORD', $CONF->certpass, $path{cert_pass});
    }

    prt("\n");
    prt("Generating the key for the certificate.\n");
    @cmd = ();
    push @cmd, 'openssl', 'genrsa';
    if (eval { $CONF->can('certpass') } && $CONF->certpass) {
        push @cmd, '-des3';
        push @cmd, '-passout', 'pass:' . $CONF->certpass;
    }
    push @cmd, '-out', $path{cert_key}, '2048';
    $result = run_cmd(@cmd);
    check_file($path{cert_key}, $result);
    chmod 0400, $path{cert_key}
      or croak("ERROR: problem setting permissions on $path{cert_key}\n");

    prt("\n");
    prt("Generating the csr.\n");
    my $cert_subject = gen_subject($path{name});
    @cmd = ();
    push @cmd, 'openssl', 'req';
    push @cmd, '-batch';
    push @cmd, '-config', $CONF->caconf;
    push @cmd, '-key',    $path{cert_key};
    push @cmd, '-new';
    push @cmd, '-sha256';
    push @cmd, '-out',  $path{cert_csr};
    push @cmd, '-subj', $cert_subject;

    # Put the password in a file to pass to openssl if the cert
    # is to have a password.
    my $certpassfile;
    if (eval { $CONF->can('certpass') } && $CONF->certpass) {
        $certpassfile = save_tmp_password($certpassfile);
        push @cmd, '-passin', "file:$certpassfile";
    }

    $result = run_cmd(@cmd);
    check_file($path{cert_csr}, $result);

    # Delete the password file now that it is not needed anymore.
    if (-e $certpassfile) {
        unlink $certpassfile
          or croak "ERROR: problem deleting $certpassfile\n";
    }

    prt("\n");
    prt("Signing the certificate.\n");
    # Set the server extenstion
    my $cert_extension;
    if ($CONF->certtype eq 'server') {
        $cert_extension = 'server_cert';
    } else {
        $cert_extension = 'usr_cert';
    }
    # Put the ca password into a file that is passed to openssl.
    my $capassfile = save_tmp_password($CONF->capass);
    # Generate the command to sign the certificate.
    @cmd = ();
    push @cmd, 'openssl', 'ca';
    push @cmd, '-batch';
    push @cmd, '-config',     $CONF->caconf;
    push @cmd, '-extensions', $cert_extension;
    push @cmd, '-days',       3650;
    push @cmd, '-notext';
    push @cmd, '-in',     $path{cert_csr};
    push @cmd, '-out',    $path{cert_pem};
    push @cmd, '-passin', "file:$capassfile";
    $result = run_cmd(@cmd);
    check_file($path{cert_pem}, $result);

    # Set the file permissions on the certificate.
    chmod 0444, $path{cert_pem}
      or croak("ERROR: problem setting permissions on $path{cert_pem}\n");

    # Delete the password file now that it is not needed anymore.
    unlink $capassfile
      or croak "ERROR: problem deleting $capassfile\n";

    return;
}

#------------------------------------------------------------------------
# Find an openssl cert

sub find_openssl {
    my ($path_ref) = @_;
    my %path = %{$path_ref};

    my $cert_file;
    my $cert_subject = gen_subject($path{name});

    open(my $fh, '<', $CONF->caconf)
      or die 'ERROR: problem opening ' . $CONF->caconf . "\n";
    my %ca_conf = ();
    while (<$fh>) {
        chomp;
        my $inline = $_;
        if ($inline =~ /\s* (\S+) \s* = \s* (.*)/xms) {
            $ca_conf{$1} = $2;
            $ca_conf{$1} =~ s/\s+$//;
        }
    }
    close $fh or croak('ERROR: problem closing ' . $CONF->caconf . "\n");

    my $idx_file = $ca_conf{'database'};
    if (!-e $idx_file) {
        die "ERROR: openssl config file $idx_file not found\n";
    }

    open(my $idx, '<', $idx_file)
      or die "ERROR: problem opening $idx_file\n";
    while (<$idx>) {
        chomp;
        my $inline = $_;
        if ($inline =~ /\w \s+ (\S+) \s+ (\d+) \s+ (\S+) \s* (.*)/xms) {
            my $status   = $1;
            my $stamp    = $2;
            my $cert_idx = $3;
            my $fld      = $4;
            my $subject  = $5;
            if ($cert_subject =~ /^\s* $subject \s*$/) {
                my $cert_file = "$ca_conf{'new_certs_dir'}/${cert_idx}.pem";
                if (!-e $cert_file) {
                    my $m = "Certificate found in index, but $cert_file "
                      . "not found\n";
                    prt("WARN: $m");
                    die 'ERROR: Record will have to be manually deleted from '
                      . "$idx_file\n";
                }
                last;
            }
        }
    }
    close $idx or croak("ERROR: problem closing $idx_file\n");

    if (!$cert_file) {
        die "ERROR: certificate not found matching: $cert_subject";
    }

    return ($cert_subject, $cert_file);
}

#------------------------------------------------------------------------
# Display openssl certs issued

sub display_openssl {
    my ($path_ref) = @_;
    my ($cert_subject, $cert_file) = find_openssl($path_ref);
    prt("Subject: $cert_subject\n");
    prt("File: $cert_file\n");
    return;
}

#------------------------------------------------------------------------
# Revoke an openssl certificate

sub revoke_openssl {
    my ($path_ref) = @_;
    my %path = %{$path_ref};

    my ($cert_subject, $cert_file) = find_openssl($path_ref);

    # Put the password in a file to pass to openssl if the cert
    # is to have a password.
    my $capassfile = save_tmp_password($CONF->capass);

    my @cmd = ();
    push @cmd, 'openssl', 'ca';
    push @cmd, '-batch';
    push @cmd, '-config', $CONF->caconf;
    push @cmd, '-revoke', $cert_file;
    push @cmd, '-passin', "file:$capassfile";

    run_cmd(@cmd);

    if (-e $capassfile) {
        unlink $capassfile
          or croak("ERROR: problem deleting file $capassfile\n");
    }

    return;
}

##############################################################################
# Main routine
##############################################################################

GetOptions(
    'caconf=s'   => \$opt_caconf,
    'cacert=s'   => \$opt_cacert,
    'cakey=s'    => \$opt_cakey,
    'capass=s'   => \$opt_capass,
    'certpass=s' => \$opt_certpass,
    'certroot=s' => \$opt_certroot,
    'certtype=s' => \$opt_certtype,
    'conf=s'     => \$opt_conf,
    'debug'      => \$opt_debug,
    'example'    => \$opt_example,
    'find'       => \$opt_find,
    'help'       => \$opt_help,
    'manual'     => \$opt_manual,
    'revoke'     => \$opt_revoke,
    'ssltype=s'  => \$opt_ssltype
) or die "ERROR: invalid command line arguments\n";

# Flush output immediately
$| = 1;

# Show a configuration example
if ($opt_example) {
    conf_example();
    exit 1;
}

# help the poor souls out
if ($opt_manual) {
    pod2usage(-verbose => 2);
}
if ($opt_help || !$ARGV[0] || $ARGV[0] eq 'help') {
    pod2usage(-verbose => 0);
}

# Command line parameter
my $this_name = $ARGV[0];

# read configuration file if it exists
my $conf_file;
if ($opt_conf && -e $opt_conf) {
    $conf_file = $opt_conf;
} elsif (-e '/etc/make-signed-cert.conf') {
    $conf_file = '/etc/make-signed-cert.conf';
}
read_config($conf_file);

# Validate input
my %paths = set_paths($this_name);

if ($CONF->ssltype eq 'gnutls') {
    # Make sure we have the CA certificate file
    if (!$CONF->cacert) {
        prt("ERROR: --cacert must be specified for GNUTLS ssltype\n");
        pod2usage(-verbose => 0);
    }
    if (!-e $CONF->cacert) {
        prt('ERROR: cacert file not found' . $CONF->cacert . "\n");
        pod2usage(-verbose => 0);
    }
    $paths{cacert} = $CONF->cacert;
    # Make sure we have the CA Key file
    if (!$CONF->cakey) {
        prt("ERROR: --cakey must be specified for GNUTLS ssltype\n");
        pod2usage(-verbose => 0);
    }
    if (!-e $CONF->cakey) {
        prt('ERROR: cakey file not found' . $CONF->cakey . "\n");
        pod2usage(-verbose => 0);
    }
    $paths{cakey} = $CONF->cakey;
    gen_gnutls(\%paths);
} elsif ($CONF->ssltype eq 'openssl') {
    if ($opt_find) {
        find_openssl(\%paths);
    } elsif ($opt_revoke) {
        revoke_openssl(\%paths);
    } else {
        if ($CONF->certtype !~ /^(server|user)$/xms) {
            prt('ERROR: unknown certtype (' . $CONF->certtype . ")\n");
        }
        gen_openssl(\%paths);
    }
} else {
    prt('ERROR: unknown ssltype ' . $CONF->ssltype . "\n");
    pod2usage(-verbose => 0);
}

exit;

__END__

=head1 NAME

make-signed-cert - make a signed certificate

=head1 SYNOPSIS

make-signed-cert <name> [--ssltype=(gnutls|openssl)]
[--conf=<filename>] [--caconf=<filename>] [--cacert=/path/ro/ca-cert]
[--cakey=/path/to/ca-key] [--certroot=<path>] [--certtype=user|server]
[--find] [--example] [--help] [--manual] [--debug]

=head1 DESCRIPTION

Create X509 certificate.

For server certificates the name should be the fully qualified domain
name and for user certificates the name should not include a '.'.

The command line options capass, caroot, caname, certpass, and
certroot can be specified in the configuration file
/etc/make-signed-cert.conf.  The file uses a simple property=value
format.

=head1 OPTIONS AND ARGUMENTS

=over 4

=item --type=(gnutls|openssl)

The type determines whether openssl or gnutls will be used to generate
the certificate.  The default is to use openssl.

=item --conf=<filename>

The configuration file to use.  If not supplied the script will attempt
to read ./make-ca.conf and /etc/make-ca.conf on that order.

=item --find

Search for the certificate specified in the database of certificates
issued on this host.

=item --example

Print an example configuration file.

=item --caconf=<filename>

The CA certificate configuration file.

=item --certroot=<path>

If specified the certificate will be written to <path>/certs and
<path>/private as appropriate.

=item --cacert=<filename>

The CA certificate file. GNUTLS only.

=item --cakey=<filename>

The CA key file. GNUTLS only.

=item --certtype=user|server

The type of certificate to generate, either server or user.  The
default is to generate a user certificate.  The name of the server
cert would be a fully qualified domain name.

=item --debug

Generate debugging messages.

=item --help

A short help message.

=item --manual

The complete documentation.

=back

=head1 EXAMPLES

=over 2

=item Generate and sign a server certificate

make-signed-cert --caconf=/etc/testca2-intermediate/openssl.conf --capass=mrbill --certtype=server ldap-test2.ca-zephyr.org

=item Generate and sign a user certificate

make-signed-cert --caconf=/etc/testca2-intermediate/openssl.conf --capass=mrbill ldap-user2

=back

=head1 AUTHOR

Bill MacAllister <bill@ca-zephyr.org>

=head1 COPYRIGHT

This software was developed for use at Shelter Cove.  All rights
reserved 2016.

=cut
