home rss search January 01, 2017

Lets Encrypt Client

a simple Automatic Certificate Management Environment (ACME) client

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.

NOTE: This script no longer works with Lets Encrypt due to multiple Lets Encrypt API changes. We suggest looking at Neil Pang's acme.sh script on GitHub.

The lets_encrypt.sh script

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.


# 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.

# 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.

# 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"

# 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

# The local name of Lets Encrypt public certificate

# 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

  # Remove spaces
  for ((i=0; i<${#tmphex}; i+=1)); do
    test "${tmphex:$i:1}" == " " || hex="${hex}${tmphex:$i:1}"

  # Add leading zero
  test $((${#hex} & 1)) == 0 || hex="0${hex}"

  # Convert to escaped string
  for ((i=0; i<${#hex}; i+=2)); do

  # Convert to binary data
  printf -- "${escapedhex}"

_request() {

  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}")" ;;

  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

  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() {

  # 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}"

  # 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:"
    END=$(( RANDOM % (10 - 5 + 1 ) + 5 ))
    for (( i=$START; i<=$END; i++ ))
       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 ;;
    printf "\n + Private Key created\n"

  # Generate a signing request
  for altname in $altnames; do
    SAN+="DNS:${altname}, "
  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

    # Challenge response consists of the challenge token and the thumbprint of our public certificate

    # 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)"

    # 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"
      printf "   Challenge is invalid ! (returned: ${status})\n"
      exit 1


  # 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() {

  # location of FreeBSD's root certificates
  if [ -f /etc/ssl/cert.pem ]; then

  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)" ;;

  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)" ;;

  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=""

# 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 ###

Setup the script environment

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.

Make a script directory, put lets_encrypt.sh in place

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

Make a directory under the web root

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 script options

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.

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

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

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.

Testing done, Now change to the real Certificate Authority Server

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.


How do I renew my certificate ?

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.

How do I revoke my certificate ?

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.

How can I monitor a certificate's expiration date ?

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.


# Check if an SSL certificate will expire in less then 30 days.
# https://calomel.org/lets_encrypt_client.html

# remote ssl domain to monitor

# 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

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

How can I contribute ?

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.

Contact Us RSS Feed Google Site Search