#!/usr/bin/perl # The LearningOnline Network with CAPA # Script to create a Certificate Authority (CA) for a LON-CAPA cluster. # # $Id: CrCA.pl,v 1.4 2019/07/18 00:28:04 raeburn Exp $ # # Copyright Michigan State University Board of Trustees # # This file is part of the LearningOnline Network with CAPA (LON-CAPA). # LON-CAPA 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 2 of the License, or # (at your option) any later version. # # LON-CAPA 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 LON-CAPA; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # /home/httpd/html/adm/gpl.txt # # http://www.lon-capa.org/ use strict; # # Expected structure # # /lonca # opensslca.cnf # cacert.pem # index.txt # /certs # /crl # /private # /requests # print(< 'City', state => 'State or Province', clustername => 'Cluster name', organization => 'Organization name', ); my ($clustername,$organization,$country,$state,$city,$email,$clusterhostname,$days,$crldays); $clusterhostname = $hostname; $country = &get_country($hostname); print "Enter state or province name\n"; $state = &get_info($fieldname{'state'}); print "Enter city name\n"; $city = &get_info($fieldname{'city'}); $email = &get_camail(); print 'Enter a name for this LON-CAPA cluster, e.g., "Lon-CAPA learning network"'."\n". 'This name will be included as the Common Name for the CA certificate.'."\n"; $clustername = &get_info($fieldname{'clustername'}); print 'Enter the organization name for this LON-CAPA cluster, e.g., "Lon CAPA certification authority"'."\n". 'This name will be included as the Organization for the CA certificate.'."\n"; $organization = &get_info($fieldname{'organization'}); print "Enter the default lifetime (in days) for each certificate created/signed by the CA for individual nodes, e.g., 3650\n"; $days = &get_days(); print "Enter the re-creation interval (in days) for the CA's certificate revocation list (CRL), e.g., 180\n"; $crldays = &get_days(); if (open(my $fh,'>',"$dir/lonca/opensslca.conf")) { print $fh <<"END"; [ ca ] default_ca = loncapa [ loncapa ] dir = $dir/lonca certificate = $dir/lonca/cacert.pem database = $dir/lonca/index.txt new_certs_dir = $dir/lonca/certs private_key = $dir/lonca/private/cakey.pem serial = $dir/lonca/serial default_crl_days = $crldays default_days = $days default_md = sha256 policy = loncapa_policy x509_extensions = certificate_extensions [ loncapa_policy ] commonName = supplied stateOrProvinceName = supplied countryName = supplied emailAddress = supplied organizationName = supplied organizationalUnitName = optional [ certificate_extensions ] basicConstraints = CA:false crlDistributionPoints = URI:http://$clusterhostname/adm/dns/loncapaCRL [ req ] default_bits = 2048 distinguished_name = loncapa_ca x509_extensions = loncapa_ca_extensions [ loncapa_ca ] commonName = $clustername localityName = $city stateOrProvinceName = $state countryName = $country emailAddress = $email organizationName = $organization [ loncapa_ca_extensions ] basicConstraints = CA:true [ crl_ext ] authorityKeyIdentifier=keyid:always,issuer:always END } else { print 'Error: failed to wtite to '."$dir/lonca/opensslca.conf. Exiting.\n"; exit; } %data = &parse_config("$dir/lonca/opensslca.conf"); my %update = &confirm_config(%data); my %changes; foreach my $field ('clustername','organization','email','country','state','city','days','crldays') { if ($data{$field} ne $update{$field}) { $changes{$field} = $update{$field}; } } if (keys(%changes)) { &save_config_changes("$dir/lonca/opensslca.conf",\%changes); } } my $sslkeypass; if (-e "$dir/lonca/private/cakey.pem") { my ($keyok,$try); print "CA key aleady exists\n"; $try = 1; while (!$keyok && $try) { $sslkeypass = &get_password('Enter the password for the CA key'); if ($sslkeypass ne '') { open(PIPE,"openssl rsa -noout -in lonca/private/cakey.pem -passin pass:$sslkeypass -check |"); my $check = ; close(PIPE); chomp($check); if ($check eq 'RSA key ok') { $keyok = 1; last; } else { print "CA key check failed. Try again? [Y/n]"; if (!&get_user_selection(1)) { $try = 0; } } } } unless ($keyok) { print "CA key check failed. Create a new key? [Y/n]"; if (&get_user_selection(1)) { $sslkeypass = &get_new_sslkeypass(); # generate SSL key unless (&make_key("$dir/lonca/private",$sslkeypass)) { print "Failed to create CA key\n"; exit; } } else { exit; } } } else { $sslkeypass = &get_new_sslkeypass(); # generate SSL key unless (&make_key("$dir/lonca/private",$sslkeypass)) { print "Failed to create CA key\n"; exit; } } my $makecacert; if (-e "$dir/lonca/cacert.pem") { print "A CA certificate exists\n"; open(PIPE,"openssl pkey -in $dir/lonca/private/cakey.pem -passin pass:$sslkeypass -pubout -outform der | sha256sum |"); my $hashfromkey = ; close(PIPE); chomp($hashfromkey); open(PIPE,"openssl x509 -in $dir/lonca/cacert.pem -pubkey | openssl pkey -pubin -pubout -outform der | sha256sum |"); my $hashfromcert = ; close(PIPE); chomp($hashfromcert); my $defsel = 0; if ($hashfromkey eq $hashfromcert) { my ($now,$starttime,$endtime,$status,%cert); my $x509 = Crypt::OpenSSL::X509->new_from_file("$dir/lonca/cacert.pem"); my @items = split(/,\s+/,$x509->subject()); foreach my $item (@items) { my ($name,$value) = split(/=/,$item); if ($name eq 'CN') { $cert{'cn'} = $value; } } $cert{'start'} = $x509->notBefore(); $cert{'end'} = $x509->notAfter(); $cert{'alg'} = $x509->sig_alg_name(); $cert{'size'} = $x509->bit_length(); $cert{'email'} = $x509->email(); my $dt = DateTime::Format::x509->parse_datetime($cert{'start'}); if (ref($dt)) { $starttime = $dt->epoch; } $dt = DateTime::Format::x509->parse_datetime($cert{'end'}); if (ref($dt)) { $endtime = $dt->epoch; } $now = time; if (($starttime ne '') && ($endtime ne '')) { if ($endtime <= $now) { $status = 'previous'; print "Current CA certificate expired $cert{'end'}\n"; print 'Create a new certificate? [Y/n]'; $defsel = 1; } elsif ($starttime > $now) { $status = 'future'; print "Current CA certificate will be valid after $cert{'start'}\n"; print 'Create a new certificate? [y/N]'; } else { $status eq 'active'; print "Current CA certificate valid until $cert{'end'}".' '. "Signature Algorithm: $cert{'alg'}; Public Key size: $cert{'size'}\n"; print 'Create a new certificate? [y/N]'; } } else { print "Could not determine validity of current CA certificate\n"; print 'Create a new certificate? [Y/n]'; $defsel = 1; } } else { print "Current CA certificate does not match key.\n"; print 'Create a new certificate? [Y/n]'; $defsel = 1; } if (&get_user_selection($defsel)) { $makecacert = 1; } } else { $makecacert = 1; } if ($makecacert) { print "Enter the lifetime (in days) for the CA root certificate distributed to all nodes, e.g., 3650\n"; my $cadays = &get_days(); unless (&make_ca_cert("$dir/lonca/private","$dir/lonca",$sslkeypass,$cadays)) { print "Failed to create CA certificate\n"; exit; } } if (!-e "$dir/lonca/index.txt") { File::Slurp::write_file("$dir/lonca/index.txt"); } if (-e "$dir/lonca/index.txt") { my $mode = 0600; chmod $mode, "$dir/lonca/index.txt"; } else { print "lonca/index.txt file is missing\n"; exit; } my $defcrlsel = 1; if (!-e "$dir/lonca/crl/loncapaCAcrl.pem") { print "No Revocation Certificate List found.\n"; print 'Create Certificate Revocation List [Y/n]'; } else { if (open(PIPE,"openssl crl -in $dir/lonca/crl/loncapaCAcrl.pem -inform pem -CAfile $dir/lonca/cacert.pem -noout 2>&1 |")) { my $crlstatus = ; close(PIPE); chomp($crlstatus); my $failmsg = "Could not determine 'valid from' and 'valid to' dates for Certificate Revocation List.\n"; if ($crlstatus =~ /OK/) { print "Current Certficate Revocation List is consistent with current CA certificate.\n"; if (open(my $fh,'<',"$dir/lonca/crl/loncapaCAcrl.pem")) { my $pem_crl = ''; while (my $line=<$fh>) { chomp($line); next if ($line eq '-----BEGIN X509 CRL-----'); next if ($line eq '-----END X509 CRL-----'); $pem_crl .= $line; } close($fh); my $der_crl = MIME::Base64::decode_base64($pem_crl); if ($der_crl ne '') { my $decoded = Crypt::X509::CRL->new( crl => $der_crl ); if (ref($decoded)) { if ($decoded->error) { print $failmsg; } else { my $starttime = $decoded->this_update; my $endtime = $decoded->next_update; if (($endtime ne '') && ($endtime < time)) { print "Certificate Revocation List is no longer valid.\n"; } elsif ($starttime > time) { print "Certificate Revocation List will become valid in the future.\n"; } elsif (($starttime ne '') && ($endtime ne '')) { my $showstart = localtime($starttime); my $showend = localtime($endtime); print "Certificate Revocation List valid from: $showstart to: $showend\n"; $defcrlsel = 0; } else { print $failmsg; } } } else { print $failmsg; } } else { print $failmsg; } } else { print $failmsg; } } else { print "Current Certificate Revocation List is not consistent with current CA certificate.\n"; } if ($defcrlsel) { print 'Create Certificate Revocation List [Y/n]'; } else { print 'Create Certificate Revocation List [y/N]'; } } else { print "Could not check Certificate Revocation List status.\n"; print 'Create Certificate Revocation List [Y/n]'; } } if (&get_user_selection($defcrlsel)) { if (open(PIPE,"openssl ca -gencrl -keyfile $dir/lonca/private/cakey.pem -cert $dir/lonca/cacert.pem -out $dir". "/lonca/crl/loncapaCAcrl.pem -config $dir/lonca/opensslca.conf -passin pass:$sslkeypass |")) { close(PIPE); if (-e "$dir/lonca/crl/loncapaCAcrl.pem") { if (open(PIPE,"openssl crl -in $dir/lonca/crl/loncapaCAcrl.pem -inform pem -CAfile $dir/lonca/cacert.pem -noout 2>&1 |")) { my $revoked = ; close(PIPE); chomp($revoked); if ($revoked eq 'verify OK') { print "Certificate Revocation List created\n"; } else { print "Certificate Revocation List status: $revoked\n"; } } else { print "Could not check Certificate Revocation List status\n"; } } else { print "Failed to create Certificate Revocation List\n"; } } else { print "Failed to create Certificate Revocation List\n"; } } exit(0); sub cafield_to_key { my %mapping = ( city => 'localityName', state => 'stateOrProvinceName', country => 'countryName', email => 'emailAddress', organization => 'organizationName', clustername => 'commonName', ); return %mapping; } sub field_to_key { my %mapping = ( days => 'default_days', crldays => 'default_crl_days', ); } sub parse_config { my ($filepath) = @_; my (%fields,%data); if (open(my $fh,'<',$filepath)) { my $currsection; while(<$fh>) { chomp(); s/(^\s+|\s+$)//g; if (/^\[\s*([^\s]+)\s*\]/) { $currsection = $1; } elsif (/^([^=]+)=([^=]+)$/) { my ($key,$value) = ($1,$2); $key =~ s/\s+$//; $value =~ s/^\s+//; if ($currsection ne '') { $fields{$currsection}{$key} = $value; } } } close($fh); } if (ref($fields{'loncapa_ca'}) eq 'HASH') { my %ca_mapping = &cafield_to_key(); foreach my $key (keys(%ca_mapping)) { $data{$key} = $fields{'loncapa_ca'}{$ca_mapping{$key}}; } } if (ref($fields{'loncapa'}) eq 'HASH') { my %mapping = &field_to_key(); foreach my $key (keys(%mapping)) { $data{$key} = $fields{'loncapa'}{$mapping{$key}}; } } return %data; } sub save_config_changes { my ($filepath,$updated) = @_; return unless (ref($updated) eq 'HASH'); my %mapping = &field_to_key(); my %ca_mapping = &cafield_to_key(); my %revmapping = reverse(%mapping); my %rev_ca_mapping = reverse(%ca_mapping); my $lines; if (open(my $fh,'<',$filepath)) { my $currsection; while(<$fh>) { my $line = $_; chomp(); s/(^\s+|\s+$)//g; my $newline; if (/^\[\s*([^\s]+)\s*\]/) { $currsection = $1; } elsif (/^([^=]+)=([^=]*)$/) { my ($origkey,$origvalue) = ($1,$2); my ($key,$value) = ($origkey,$origvalue); $key =~ s/\s+$//; $value =~ s/^\s+//; if ($currsection eq 'loncapa_ca') { if ((exists($rev_ca_mapping{$key})) && (exists($updated->{$rev_ca_mapping{$key}}))) { if ($value eq '') { if ($origvalue eq '') { $origvalue = ' '; } $origvalue .= $updated->{$rev_ca_mapping{$key}}; } else { $origvalue =~ s/\Q$value\E/$updated->{$rev_ca_mapping{$key}}/; } $newline = $origkey.'='.$origvalue."\n"; } } elsif ($currsection eq 'loncapa') { if ((exists($revmapping{$key})) && (exists($updated->{$revmapping{$key}}))) { if ($value eq '') { if ($origvalue eq '') { $origvalue = ' '; } $origvalue .= $updated->{$revmapping{$key}}; } else { $origvalue =~ s/\Q$value\E/$updated->{$revmapping{$key}}/; } $newline = $origkey.'='.$origvalue."\n"; } } } if ($newline) { $lines .= $newline; } else { $lines .= $line; } } close($fh); if (open(my $fout,'>',$filepath)) { print $fout $lines; close($fout); } else { print "Error: failed to open '$filepath' for writing\n"; } } return; } # # get_hostname() prompts the user to provide the server's hostname. # # If invalid input is provided, the routine is called recursively # until, a valid hostname is provided. # sub get_hostname { my $hostname; print 'Enter the hostname of this server, e.g., loncapa.somewhere.edu'."\n"; my $choice = ; chomp($choice); $choice =~ s/(^\s+|\s+$)//g; if ($choice eq '') { print "Hostname you entered was either blank or contanied only white space.\n"; } elsif ($choice =~ /^[\w\.\-]+$/) { $hostname = $choice; } else { print "Hostname you entered was invalid -- a hostname may only contain letters, numbers, - and .\n"; } while ($hostname eq '') { $hostname = &get_hostname(); } print "\n"; return $hostname; } sub get_new_sslkeypass { my $sslkeypass; my $flag=0; # get password for SSL key while (!$flag) { $sslkeypass = &make_passphrase(); if ($sslkeypass) { $flag = 1; } else { print "Invalid input (a password is required for the CA key).\n"; } } return $sslkeypass; } sub make_passphrase { my ($got_passwd,$firstpass,$secondpass,$passwd); my $maxtries = 10; my $trial = 0; while ((!$got_passwd) && ($trial < $maxtries)) { $firstpass = &get_password('Enter a password for the CA key (at least 6 characters long)'); if (length($firstpass) < 6) { print('Password too short.'."\n". 'Please choose a password with at least six characters.'."\n". 'Please try again.'."\n"); } elsif (length($firstpass) > 30) { print('Password too long.'."\n". 'Please choose a password with no more than thirty characters.'."\n". 'Please try again.'."\n"); } else { my $pbad=0; foreach (split(//,$firstpass)) {if ((ord($_)<32)||(ord($_)>126)){$pbad=1;}} if ($pbad) { print('Password contains invalid characters.'."\n". 'Password must consist of standard ASCII characters.'."\n". 'Please try again.'."\n"); } else { $secondpass = &get_password('Enter password a second time'); if ($firstpass eq $secondpass) { $got_passwd = 1; $passwd = $firstpass; } else { print('Passwords did not match.'."\n". 'Please try again.'."\n"); } } } $trial ++; } return $passwd; } sub get_password { my ($prompt) = @_; local $| = 1; print $prompt.': '; my $newpasswd = ''; Term::ReadKey::ReadMode('raw'); my $key; while(ord($key = Term::ReadKey::ReadKey(0)) != 10) { if(ord($key) == 127 || ord($key) == 8) { chop($newpasswd); print "\b \b"; } elsif(!ord($key) < 32) { $newpasswd .= $key; print '*'; } } Term::ReadKey::ReadMode('normal'); print "\n"; return $newpasswd; } # # make_key() generates CA root key # sub make_key { my ($keydir,$sslkeypass) = @_; # generate SSL key my $created; if (($keydir ne '') && ($sslkeypass ne '')) { if (-f "$keydir/cakey.pem") { my $mode = 0600; chmod $mode, "$keydir/cakey.pem"; } open(PIPE,"openssl genrsa -aes256 -passout pass:$sslkeypass -out $keydir/cakey.pem 2048 2>&1 |"); close(PIPE); if (-f "$keydir/cakey.pem") { my $mode = 0400; chmod $mode, "$keydir/cakey.pem"; $created= 1; } } else { print "Key creation failed. Missing one or more of: certificates directory, key name\n"; } return $created; } # # make_ca_cert() generates CA root certificate # sub make_ca_cert { my ($keydir,$certdir,$sslkeypass,$cadays) = @_; # generate SSL cert for CA my $created; if ((-d $keydir) && (-d $certdir) && ($sslkeypass ne '') && ($cadays =~ /^\d+$/) && ($cadays > 0)) { open(PIPE,"openssl req -x509 -key $keydir/cakey.pem -passin pass:$sslkeypass -new -days $cadays -batch -config $certdir/opensslca.conf -out $certdir/cacert.pem |"); close(PIPE); if (-f "$certdir/cacert.pem") { my $mode = 0600; chmod $mode, "$certdir/cacert.pem"; $created= 1; } } else { print "Creation of CA root certificate failed. Missing one or more of: CA directory, CA key directory, CA passphrase, or certificate lifetime (number of days).\n"; } return $created; } sub get_camail { my $camail; my $flag=0; # get Certificate Authority E-mail while (!$flag) { print(<; chomp($choice); if (($choice ne '') && ($choice =~ /^[^\@]+\@[^\@]+$/)) { $camail=$choice; $flag=1; } else { print "Invalid input (a valid email address is required).\n"; } } return $camail; } sub ssl_info { print(<; chomp($choice); if ($choice ne '') { if (Locale::Country::code2country(lc($choice))) { $country=uc($choice); $flag=1; } else { print "Invalid input -- a valid two letter country code is required\n"; } } elsif (($choice eq '') && ($posscountry ne '')) { $country = $posscountry; $flag = 1; } else { print "Invalid input -- a country code is required\n"; } } return $country; } sub get_info { my ($typename) = @_; my $value; my $choice = ; chomp($choice); $choice =~ s/(^\s+|\s+$)//g; if ($choice eq '') { print "$typename you entered was either blank or contained only white space.\n"; } else { $value = $choice; } while ($value eq '') { $value = &get_info($typename); } print "\n"; return $value; } sub get_days { my $value; my $choice = ; chomp($choice); $choice =~ s/(^\s+|\s+$)//g; if ($choice eq '') { print "The value you entered was either blank or contained only white space.\n"; } elsif ($choice !~ /^\d+$/) { print "The value you entered contained invalid characters -- you must enter just an integer.\n"; } else { $value = $choice; } while ($value eq '') { $value = &get_days(); } print "\n"; return $value; } sub confirm_config { my (%data) = @_; my $flag = 0; while (!$flag) { print(<; chomp($choice); if ($choice == 1) { print(<; chomp($choice2); $data{'clustername'}=$choice2; chomp($choice2); $data{'organization'}=$choice2; } elsif ($choice == 3) { print(<; chomp($choice2); $data{'country'} = uc($choice2); } elsif ($choice == 4) { print(<; chomp($choice2); $data{'state'}=$choice2; } elsif ($choice == 5) { print(<; chomp($choice2); $data{'city'}=$choice2; } elsif ($choice == 6) { print(<; chomp($choice2); $data{'email'}=$choice2; } elsif ($choice == 7) { print(<; chomp($choice2); $choice2 =~ s/\D//g; $data{'days'}=$choice2; } elsif ($choice == 8) { print(<; chomp($choice2); $choice2 =~ s/\D//g; $data{'crldays'}=$choice2; } elsif ($choice == 9) { $flag=1; foreach my $key (keys(%data)) { $data{$key} =~ s{/}{ }g; } } else { print "Invalid input.\n"; } } return %data; } sub get_user_selection { my ($defaultrun) = @_; my $do_action = 0; my $choice = ; chomp($choice); $choice =~ s/(^\s+|\s+$)//g; my $yes = 'y'; if ($defaultrun) { if (($choice eq '') || ($choice =~ /^\Q$yes\E/i)) { $do_action = 1; } } else { if ($choice =~ /^\Q$yes\E/i) { $do_action = 1; } } return $do_action; }