Lets Encrypt offers free, automated domain validated SSL certificates. The Lets Encrypt developers provide a python client for certificate negotiation, but we believe the client is far too complicated and requires unnecessary elevated privileges. A simple UNIX shell client should be able to negotiate with the Lets Encrypt ACME server and create a valid domain name certificate.
With simplicity in mind, this script is written for the BASH shell and only requires openssl, for certificate manipulation, and curl to negotiate with the ACME service. The client runs as an unprivileged user, does not listen on any network ports and creates the certificates in a secure directory of your choosing. The program expects to be run on the web server, proxy or ssl terminator in order to negotiate with the ACME service.
This lets_encrypt.sh script is a simplified branch of the original script by lukas2511/letsencrypt.sh and all credit for the script's design goes to the original developer. If you require more functionality then our version of the script can provide, please contact lukas on Github.
When you execute the script, a random private account key is generated and registered with Lets Encrypt. We then make a separate private key and certificate signing request for the domain you are requesting a certificate for. A "thumbprint", alpha numeric id is generated and a file with this id is placed in the web root under .well-known/acme-challenge . Using curl, the script contacts the Lets Encrypt ACME server expressing our request for a certificate. The Lets Encrypt ACME server connects to your local web server and downloads the "thumbprint" id from the predefined path proving to Lets Encrypt that you own the domain name. The script then collects the signed certificate for the domain from the ACME server and formats the certificate for web server use. The resulting certificate chain is in the PEM container format and fully compatible with Apache, Nginx and H2O. The entire process should take around ten(10) seconds to complete.
You are welcome to copy and paste the script from the scrollable window below. We call the script, "lets_encrypt.sh", but you can name it anything you like. Make sure check the first line of the script and define the full path to bash. /usr/local/bin/bash for FreeBSD and /bin/bash for Ubuntu. Further down on this page we discuss how to setup and run the script.
#!/usr/local/bin/bash # shell script hardening set -euf -o pipefail # # Lets Encrypt Certificate Generator # https://calomel.org/lets_encrypt_client.html # lets_encrypt.sh v0.07 # # The script will generate a new certificate for the domain specified and # negotiate with the Lets Encrypt ACME server to save a signed certificate # chain. # # dependency: bash (/dev/fd), openssl, curl ################ options start################# # The primary domain name followed by any alternative names we are requesting a # certificate for. Use a space separated list. Each domain name will be tested # by the ACME server. #DOMAINS="example.org www.example.org mail.example.org" DOMAINS="example.org www.example.org" # The directory the script is run from and where all certificates will be # stored under. This directory should be secure and not under the web root. BASEDIR="/tools/lets_encrypt" # The full path to the web directory our script will write the temporary # negotiation file. The Lets Encrypt service will then connect to our web # server to collect this temporary file verifying we own the domain. Our web # server can serve the file through http or https and 301 redirection are # allowed. WEBDIR="/var/www/.well-known/acme-challenge" # SSL Certificates type. RSA at 2048 bit should be used for wider compatability # including older clients. ECDSA prime 256 bits is prefered for its smaller key # size, faster server side processing and better security model. Options: "rsa" # or "ecdsa" CERTYPE="rsa" #CERTYPE="ecdsa" # The Lets Encrypt certificate authority URL CA="https://acme-staging.api.letsencrypt.org" # testing server, high rate limits. "Fake LE Intermediate X1" #CA="https://acme-v01.api.letsencrypt.org" # official server, rate limited to 5 certs per 7 days ################ options end ################## # The license file the script will automatically accept for you LICENSE="https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf" # The local name of Lets Encrypt public certificate ROOTCERT="lets-encrypt-x3-cross-signed.pem.txt" # check the path to the openssl configuration file OPENSSL_CNF="$(openssl version -d | cut -d'"' -f2)/openssl.cnf" urlbase64() { # urlbase64: base64 encoded string with '+' replaced with '-' and '/' replaced with '_' openssl base64 -e | tr -d '\n\r' | sed 's/=*$//g' | tr '+/' '-_' } hex2bin() { # Store hex string from stdin tmphex="$(cat)" # Remove spaces hex='' for ((i=0; i<${#tmphex}; i+=1)); do test "${tmphex:$i:1}" == " " || hex="${hex}${tmphex:$i:1}" done # Add leading zero test $((${#hex} & 1)) == 0 || hex="0${hex}" # Convert to escaped string escapedhex='' for ((i=0; i<${#hex}; i+=2)); do escapedhex=$escapedhex\\x${hex:$i:2} done # Convert to binary data printf -- "${escapedhex}" } _request() { tempcont="$(mktemp)" case "$1" in "get" ) statuscode="$(curl -s -w "%{http_code}" -o "${tempcont}" "${2}")" ;; "head" ) statuscode="$(curl -s -w "%{http_code}" -o "${tempcont}" "${2}" -I)" ;; "post" ) statuscode="$(curl -s -w "%{http_code}" -o "${tempcont}" "${2}" -d "${3}")" ;; esac if [[ ! "${statuscode:0:1}" = "2" ]]; then printf '%s\n' " ERROR: sending ${1}-request to ${2} (Status ${statuscode})" >&2 printf '%s\n' >&2 printf '%s\n' "Details:" >&2 printf '%s\n' "$(<"${tempcont}"))" >&2 rm -f "${tempcont}" exit 1 fi cat "${tempcont}" rm -f "${tempcont}" } thumb_print() { # Collect the public components from the new private key and calculate the # thumbprint which the ACME server will challenge pubExponent64="$(printf "%06x" "$(openssl rsa -in "${BASEDIR}/private_account_key.pem" -noout -text | grep publicExponent | head -1 | cut -d' ' -f2)" | hex2bin | urlbase64)" pubMod64="$(printf '%s' "$(openssl rsa -in "${BASEDIR}/private_account_key.pem" -noout -modulus | cut -d'=' -f2)" | hex2bin | urlbase64)" thumbprint="$(printf '%s' "$(printf '%s' '{"e":"'"${pubExponent64}"'","kty":"RSA","n":"'"${pubMod64}"'"}' | shasum -a 256 | awk '{print $1}')" | hex2bin | urlbase64)" } signed_request() { # Encode payload as urlbase64 payload64="$(printf '%s' "${2}" | urlbase64)" # Retrieve nonce from acme-server nonce="$(_request head "${CA}/directory" | grep Replay-Nonce: | awk -F ': ' '{print $2}' | tr -d '\n\r')" # Build header with the public key and algorithm information header='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}}' # Build another header containing the previously received nonce and encode the nonce as urlbase64 protected='{"alg": "RS256", "jwk": {"e": "'"${pubExponent64}"'", "kty": "RSA", "n": "'"${pubMod64}"'"}, "nonce": "'"${nonce}"'"}' protected64="$(printf '%s' "${protected}" | urlbase64)" # Sign the header with the nonce and the payload with the private key and the encode the signature as urlbase64 signed64="$(printf '%s' "${protected64}.${payload64}" | openssl dgst -sha256 -sign "${BASEDIR}/private_account_key.pem" | urlbase64)" # Send header + extended header + payload + signature to the acme-server data='{"header": '"${header}"', "protected": "'"${protected64}"'", "payload": "'"${payload64}"'", "signature": "'"${signed64}"'"}' _request post "${1}" "${data}" } sign_domain() { domain="${1}" altnames="${*}" # create a directory to keep the domain's certificates in if [[ ! -e "${BASEDIR}/${domain}" ]]; then printf " + Make directory ${BASEDIR}/${domain}\n" mkdir -p "${BASEDIR}/${domain}" fi # Create a new private key for the domain. To add a bit of entropy to the # process, a simple loop will randomly generate between five(5) and ten(10) # private keys and the last key created will be used for the certificate # signing request. A loop is not necessary on native hardware, but may help # seed virtual machine (VM) entropy. printf " + Seed entropy by generating random keys:" START=1 END=$(( RANDOM % (10 - 5 + 1 ) + 5 )) for (( i=$START; i<=$END; i++ )) do printf " $i" case "$CERTYPE" in "rsa" ) openssl genrsa -out "${BASEDIR}/${domain}/${domain}-privatekey.pem" 2048 2> /dev/null > /dev/null ;; "ecdsa" ) openssl ecparam -genkey -name prime256v1 -out "${BASEDIR}/${domain}/${domain}-privatekey.pem" 2> /dev/null > /dev/null ;; esac done printf "\n + Private Key created\n" # Generate a signing request SAN="" for altname in $altnames; do SAN+="DNS:${altname}, " done SAN="${SAN%%, }" printf " + Generate signing request\n" openssl req -new -sha256 -key "${BASEDIR}/${domain}/${domain}-privatekey.pem" -out "${BASEDIR}/${domain}/${domain}-certsignrequest.csr" -subj "/CN=${domain}/" -reqexts SAN -config <(cat "${OPENSSL_CNF}" <(printf "[SAN]\nsubjectAltName=%s" "${SAN}")) > /dev/null # Request and respond to challenges for altname in $altnames; do # Ask the acme-server for new challenge token and extract them from the resulting json block printf " + Request challenge for ${altname}\n" response="$(signed_request "${CA}/acme/new-authz" '{"resource": "new-authz", "identifier": {"type": "dns", "value": "'"${altname}"'"}}')" challenges="$(printf '%s\n' "${response}" | grep -Eo '"challenges":[^\[]*\[[^]]*]')" challenge="$(printf "%s" "${challenges//\{/$'\n'{}}" | grep 'http-01')" challenge_token="$(printf '%s' "${challenge}" | grep -Eo '"token":\s*"[^"]*"' | cut -d'"' -f4 | sed 's/[^A-Za-z0-9_\-]/_/g')" challenge_uri="$(printf '%s' "${challenge}" | grep -Eo '"uri":\s*"[^"]*"' | cut -d'"' -f4)" if [[ -z "${challenge_token}" ]] || [[ -z "${challenge_uri}" ]]; then printf " Error: Can't retrieve challenges (${response})\n" exit 1 fi # Challenge response consists of the challenge token and the thumbprint of our public certificate keyauth="${challenge_token}.${thumbprint}" # Store challenge response in the web directory printf '%s' "${keyauth}" > "${WEBDIR}/${challenge_token}" chmod a+r "${WEBDIR}/${challenge_token}" # Request the acme-server to verify our challenge and wait until the request is valid printf " + Respond to challenge for ${altname}\n" result="$(signed_request "${challenge_uri}" '{"resource": "challenge", "keyAuthorization": "'"${keyauth}"'"}')" status="$(printf '%s\n' "${result}" | grep -Eo '"status":\s*"[^"]*"' | cut -d'"' -f4)" # Loop until the status of the request is accepted while [[ "${status}" = "pending" ]]; do sleep 1 status="$(_request get "${challenge_uri}" | grep -Eo '"status":\s*"[^"]*"' | cut -d'"' -f4)" done # Remove the temporary challenge file from the web directory rm -f "${WEBDIR}/${challenge_token}" # Check the status of the ACME server negotiation if [[ "${status}" = "valid" ]]; then printf " + Challenge accepted\n" else printf " Challenge is invalid ! (returned: ${status})\n" exit 1 fi done # create domain certificate printf " + Create domain certificate\n" csr64="$(openssl req -in "${BASEDIR}/${domain}/${domain}-certsignrequest.csr" -outform DER | urlbase64)" crt64="$(signed_request "${CA}/acme/new-cert" '{"resource": "new-cert", "csr": "'"${csr64}"'"}' | openssl base64 -e)" printf -- '-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n' "${crt64}" > "${BASEDIR}/${domain}/${domain}-certchain.pem" # add the intermediate lets encrypt public certificate to the chain printf " + Add intermediate certificate to chain\n" cat "${BASEDIR}/${ROOTCERT}" >> "${BASEDIR}/${domain}/${domain}-certchain.pem" printf " + Complete.\n" } inspect() { domain="${1}" rootcerts="/etc/ssl/" # location of FreeBSD's root certificates if [ -f /etc/ssl/cert.pem ]; then rootcerts="/etc/ssl/cert.pem" fi printf "\n\n Certificate Inspection\n" printf " ------------------------\n" case "$CERTYPE" in "rsa" ) printf "\nMD5 signatures must be equal\n\n" md5privatekey="$(openssl rsa -noout -modulus -in ${BASEDIR}/${domain}/${domain}-privatekey.pem | openssl md5)" md5certsignrequest="$(openssl req -noout -modulus -in ${BASEDIR}/${domain}/${domain}-certsignrequest.csr | openssl md5)" md5certchain="$(openssl x509 -noout -modulus -in ${BASEDIR}/${domain}/${domain}-certchain.pem | openssl md5)" printf " Private Key = $md5privatekey\n" printf " Cert Sign Req = $md5certsignrequest\n" printf " Cert Chain = $md5certchain\n" ;; "ecdsa" ) #md5privatekey="$(openssl ec -noout -modulus -in ${BASEDIR}/${domain}/${domain}-privatekey.pem | openssl md5)" ;; esac printf "\nLocally Inspect Certificate\n openssl x509 -in ${domain}/${domain}-certchain.pem -text -noout\n" printf "\nRemotely Inspect Certificate\n openssl s_client -CApath $rootcerts -connect ${domain}:443 \n" case "$CERTYPE" in "rsa" ) hpkp="$(openssl rsa -in ${BASEDIR}/${domain}/${domain}-privatekey.pem -outform der -pubout 2>/dev/null | openssl dgst -sha256 -binary | openssl enc -base64)" ;; "ecdsa" ) hpkp="$(openssl ec -in ${BASEDIR}/${domain}/${domain}-privatekey.pem -outform der -pubout 2>/dev/null | openssl dgst -sha256 -binary | openssl enc -base64)" ;; esac printf "\nHTTP Key Pinning\n pin-sha256=\"$hpkp\";\n" # check the issuer field and the full certificate path against the system's root certificate chain printf "\nVerify the authority and certificate chain\n" printf " "; openssl x509 -noout -in ${domain}/${domain}-certchain.pem -issuer printf " "; openssl verify -CApath $rootcerts ${ROOTCERT} printf " "; openssl verify -CApath $rootcerts -untrusted ${ROOTCERT} ${domain}/${domain}-certchain.pem printf "\n\n" } ## ## Lets Encrypt main() ## printf "\n Lets Encrypt Certificate Generator\n" printf " ------------------------------------\n" printf "\nInitialize the environment\n\n" # Change directory to BASEDIR cd ${BASEDIR} # Update the Lets Encrypt Authority PEM certificate printf " + Update the Lets Encrypt Authority PEM certificate\n" curl -sS -L -o ${BASEDIR}/${ROOTCERT} https://letsencrypt.org/certs/${ROOTCERT} # Generate a new account key printf " + Generate new private account key\n" openssl genrsa -out "${BASEDIR}/private_account_key.pem" "4096" 2> /dev/null > /dev/null # Calculate the thumbprint to be registered with the ACME server printf " + Calculate key thumbprint for ACME challenge\n" pubExponent64=""; pubMod64=""; thumbprint="" thumb_print # Register the new account key with the Lets Encrypt ACME service printf " + Register private account key with ACME server\n" signed_request "${CA}/acme/new-reg" '{"resource": "new-reg", "agreement": "'"$LICENSE"'"}' > /dev/null # Generate certificate for the domain printf "\nGenerate certificate for ${DOMAINS}\n\n" sign_domain ${DOMAINS} # Visually inspect the MD5 hashes inspect ${DOMAINS} # ## ### EOF ###
The script requires a directory to run from and store the certificates as well as a directory under web root for the "thumbprint" negotiation file. Once the directories are made the two(2) paths need to be defined in the script. Lets go through the details.
Create a directory to contain the script and the negotiated certificates and owned as the unprivileged user who is going to run the script. This directory is defined in the script as BASEDIR. When the script runs it will automatically make a sub directory named the same as the requested domain name so you can easily find your private key and full certificate chains. We will call our example script directory, /tools/lets_encrypt .
mkdir /tools/lets_encrypt
Change directory, use curl to download the lets_encrypt.sh script and make the script file executable.
cd /tools/lets_encrypt curl -o lets_encrypt.sh https://calomel.org/lets_encrypt.sh chmod 750 lets_encrypt.sh
The script assumes you already have a web server configured and running on the machine. After the script contacts the ACME service, an ACME server will need to verify you own the domain name which you are asking a certificate for. The ACME server will contact your domain name on http port 80 or https port 443 if 301 redirected. The ACME server is looking for a specific "thumbprint" challenge file the script generates in a specific directory tree under the web root. If your web root is /var/www/ then make a directory tree /var/www/.well-known/acme-challenge . This directory is defined in the script as WEBDIR. Make sure to allow the unprivileged user who is running this script to be able to write to the acme-challenge sub directory. The challenge file will be automatically removed from the acme-challenge directory when the script finishes.
mkdir -p /var/www/.well-known/acme-challenge chmod a+x /var/www/.well-known /var/www/.well-known/acme-challenge chown SCRIPTUSER /var/www/.well-known/acme-challenge
Edit the lets_encrypt.sh file and look for the options at the top. You need to define the DOMAINS domain name, the BASEDIR the script is run from and the full path of WEBDIR, the web directory. The script will generate an RSA certificate by default, choose "ecdsa" if you want an ECDSA client certificate.
NOTE: for testing, the script defines the variable "CA=" as the ACME staging server to generate certificates. Certificates from the staging server are not publicly valid, but just created to make sure the script and your setup works. At the end of this tutorial you will be asked to edit the "CA=" variable in the script to point to the official server. The reason the staging server is used is because the official server has strict limits on the number of certificates you can generate per day.
You are now ready to run the script.
lets_encrypt.sh can be executed from the command line or a cronjob. The script will print a summary of its actions so you can log or email the result. Here is an example of the output.
user@machine : /tools/lets_encrypt/lets_encrypt.sh Lets Encrypt Certificate Generator ------------------------------------ Initialize the environment + Update the Lets Encrypt Authority X1 PEM certificate + Generate new private account key + Calculate key thumbprint for ACME challenge + Register private account key with ACME server Generate certificate for example.org + Make directory /tools/lets_encrypt/example.org + Seed entropy by generating random keys: 1 2 3 4 5 6 7 8 9 + Private Key created + Generate signing request + Request challenge for example.org + Respond to challenge for example.org + Challenge accepted + Create domain certificate + Add intermediate certificate to chain + Complete. Certificate Inspection ------------------------ MD5 signatures must be equal Private Key = (stdin)= a3986a5925036b0018f929316ec0da1b Cert Sign Req = (stdin)= a3986a5925036b0018f929316ec0da1b Cert Chain = (stdin)= a3986a5925036b0018f929316ec0da1b PASSED Locally Inspect Certificate openssl x509 -in example.org/example.org-certchain.pem -text -noout Remotely Inspect Certificate openssl s_client -CApath /etc/ssl/cert.pem -connect example.org:443 HTTP Key Pinning pin-sha256="Hf00GDlnnS0os1cj8FaNY6pKj3c0gLhkRe5SQw/SQnM="; Verify the authority and certificate chain issuer= /C=US/O=Let's Encrypt/CN=Let's Encrypt Authority X1 public-lets-encrypt-x1-cross-signed.pem: OK example.org/example.org-certchain.pem: OK
After the script completes, and there are no errors, you will be able to find your newly generated private key and full chain domain certificate under the primary domain's directory name. If the domain name is "example.org" we will look under /tools/lets_encrypt/example.org .
user@machine : ls -al /tools/lets_encrypt/example.org -rw-r----- 1 user wheel 3456 Jan 01 02:03 example.org-certchain.pem -rw-r----- 1 user wheel 910 Jan 01 02:03 example.org-certsignrequest.csr -rw-r----- 1 user wheel 1510 Jan 01 02:03 example.org-privatekey.pem
The example.org-privatekey.pem file is your private key. The example.org-certchain.pem file is your full certificate chain. You can point your web server to these files in their current path or move them to another secure, out-of-band location.
Once your testing is done against the test certificate authority server (happy hacker fake CA) you need to edit the script to point to the production certificate authority server to generate a valid certificate. Edit the script and look for the variable at the top calld "CA=" and uncomment out the "official server" and comment the "testing server". For example:
# The Lets Encrypt certificate authority URL #CA="https://acme-staging.api.letsencrypt.org" # testing server, high rate limits. "happy hacker fake CA" CA="https://acme-v01.api.letsencrypt.org" # official server, rate limited to 5 certs per 7 days
Run the script again to generate a vaild certificate which can be used on public sites.
The script will not renew a certificate. We believe it is always more secure to create a new private key when requesting a new certificate. Before the current certificate hits the 90 day expiration date, simply run this script again. lets_encrypt.sh will generate a new private key and new domain name certificate overwriting the current files. You should be able to run the script manually or in a cronjob to automatically regenerate a certificate every 60 days or so.
The script does not have the ability to revoke a certificate. Revoked certificates require the browser client to use the Online Certificate Status Protocol (OCSP) to check the status of the certificate. OCSP has a soft-fail condition which makes OCSP useless. Google removed online certificate revocation checks from Chrome because Google considers the process inefficient and slow with little benefit. Many other clients do not bother with OCSP either. Lets Encrypt certificates are good for a maximum of 90 days, so they will expire on their own. If you are concerned with stolen certificates please use HTTP Public Key Pinning (HPKP) headers.
We use a simple shell script on our monitoring server to connect to a remote https server and check its SSL certificate expiration date. When the current date is within 30 days of the certificates expiration date the script will send an email. Just run this script from a crontab every day or so. Make sure to change the HostName and notification email from "user@emailaddress.com" to your email address.
#!/bin/sh # # Check if an SSL certificate will expire in less then 30 days. # https://calomel.org/lets_encrypt_client.html # # remote ssl domain to monitor HostName="example.org" # certificate expiration, remote check CertificateExpireDate=`echo | openssl s_client -connect $HostName:443 2>/dev/null | openssl x509 -noout -enddate | sed 's/notAfter=//'` # certificate expiration, convert to unix time UnixCertExpireDate=`date -d "$CertificateExpireDate" +"%s"` # current date in unix time UnixCurrentDate=`date +%s` # difference of the expiration date and the current date UnixTimeDiff=`expr $UnixCertExpireDate - $UnixCurrentDate` # If certificate expire in less then 30 days (2592000 seconds) send notification if [ $UnixTimeDiff -lt 2592000 ]; then echo "NOTICE: $HostName ssl certificate will expire in less then 30 days." | mail -s "$HostName ssl certificate will expire in less then 30 days" user@emailaddress.com fi
Then setup a cronjob to run the script every day. An email will be sent to the email address defined in the script.
#minute (0-59) #| hour (0-23) #| | day of the month (1-31) #| | | month of the year (1-12) #| | | | day of the week (0-6 with 0=Sun) #| | | | | commands #| | | | | | # # certificate expiration monitor @daily /monitor/scripts/ssl_cert_expire_monitor.sh
You are welcome to mail us at the contact link at the bottom of this page. If you wish to contribute on Github, please contact the original developer at lukas2511/letsencrypt.sh.