Simple PHP ACME client written by me

I wrote a simple ACME client in PHP. It is just one file, it does not use any external libraries or call other software (you need to have a webserver running for the challenge). You need PHP >= 5.4.8 with OpenSSL, cURL and JSON support (older PHP does not support OpenSSL with SHA256).

Features:

  • Correctly configured you just need to call the script, no interaction
  • Uses the webroot challenge
  • Can check lifetime of current certificate before requesting new
  • Can send E-Mails to inform admin about new cert / problems
  • Can copy new certificate to other places as well, optional including the intermediate

It will not generate the keys or the CSR, you have to do that yourself and configure this script to use them. After replacing the certificates you most likely have to reload the servers (like apache) to use them. This script will not do that.
Feel free to use this script, if you find bugs please post them here. If you think it’s worth it, I’ll publish it on github.

[code]#!/usr/bin/php

<?php /* Simple PHP ACME client 0.1 Copyright (C) 2015 Stefan Oltmanns This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero 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 Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ header("Content-type: text/plain"); ini_set('display_errors', 1); ini_set('html_errors', false); error_reporting(E_ALL); $config = array(); $challenge_file = ''; ob_start(); /******************************************************************************************* * * * * * * * * * * Configuration * * * * * * * * * */ /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * This program is not ment to be run from a web-server, especially not with public access. * It is highly recommended to store this script outside any directry a server application * has access to and run it from command-line or within a shell script. * PHP version >= 5.4.8 is required * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ /*** Data needed for account ***/ $config['account_email'] = 'webmaster@example.com'; $config['account_public_key'] = '/path/to/account/public/key.pub'; // need r access $config['account_private_key'] = '/path/to/account/private/key.sec'; // need r access $config['account_do_register'] = true; /*** Data needed for sending info mail to admiin ***/ /*** This data is not needed for communication with ACME server ***/ $config['info_mail'] = 'webmaster@example.com'; $config['info_mail_from'] = 'noreply@example.com'; $config['info_mail_header_fields'] = array( 'From: '.$config['info_mail_from'], 'MIME-Version: 1.0', 'Content-Type: text/plain; charset=utf-8' ); /* What information should be sent by this program in an email: 0 = never send emails 1 = only errors 2 = errors and new cert info 3 = all output */ $config['info_mail_level'] = 3; /*** Data about the certificate ***/ // only get new cert when lifespan of current cert is less than: $config['cert_new_time'] = 48*3600; // 48 hours // path to certificate sign request $config['cert_sign_req'] = '/path/to/certificate/sign/request.csr'; // need r access // path to the certificate $config['certificate'] = '/path/to/certificate.crt'; // need r+w access // path to the certificate copies, $config['certificate_copy'] = array( array('/some/other/path/to/certificate.crt',1), // include the intermediate certificate array('/and/another/path/to/certificate.crt',0) // do not include the intermediate certificate ); $config['intermediate_cert'] = '/path/to/intermediate/certificate.crt'; // only the intermediate certificate, not the chain! /*** Data for challenges ***/ $config['challenge_folder'] = '/path/to/webroot/.well-known/acme-challenge/'; $config['challenge_retry_n'] = 5; $config['challenge_retry_wait'] = 2; /*** Data about the ACME server ***/ //$config['ca_url'] = 'https://acme-v01.api.letsencrypt.org'; // lets encrypt server $config['ca_url'] = 'https://acme-staging.api.letsencrypt.org'; // lets encrypt test server $config['ca_terms'] = 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf'; // terms you agree to by requesting a certificate /* Return values: 0 = new certificate 1 = no new certificate (no error) -1 = error (no new certificate) */ /******************************************************************************************/ // Lets Start /*** Check current certificate lifetime ***/ $openssl_cert = (is_file($config['certificate'])) ? openssl_x509_read(file_get_contents($config['certificate'])) : false; if($openssl_cert!==false) { $cert_info = openssl_x509_parse($openssl_cert); if(($cert_info['validTo_time_t']-time())>$config['cert_new_time']) { echo 'Old certificate still valid to '.gmdate('Y-m-d H:i:s',$cert_info['validTo_time_t'])." (UTC)\nWill not request new.\n"; if($config['info_mail_level']>2) { mail($config['info_mail'],'ACME cert still valid',ob_get_contents(),implode("\r\n",$config['info_mail_header_fields']),'-oi -f '.$config['info_mail_from']); } ob_end_flush(); exit(1); } } /*** Get new certificate ***/ $json_header = json_build_header($config['account_public_key']); $pubkey_thumbprint = base64url_encode(hash('sha256',json_encode($json_header['jwk']),true)); $account_json = json_sign($json_header, /* Protected */ array('nonce'=>get_nonce($config['ca_url'])), /* Payload */ array('resource'=>'new-reg','contact'=>array('mailto:'.$config['account_email']),'agreement'=>$config['ca_terms']), /* At first we have to give the private key */ $config['account_private_key']); /*** Lets take a look at the CSR ***/ $csr = file_get_contents($config['cert_sign_req']); /* There are no PHP build-in functions to handle CSR the way we want */ $cnt=0; $csr_dec = base64_decode(trim(str_replace(array("-----BEGIN CERTIFICATE REQUEST-----","-----END CERTIFICATE REQUEST-----"),'',$csr,$cnt))); if($cnt!=2) die2('Could not read CSR'); $csr_json = json_sign($json_header, /* Protected */ array('nonce'=>get_nonce($config['ca_url'])), /* Payload */ array('resource'=>'new-cert','csr'=>base64url_encode($csr_dec))); /*** Now handle the domains ***/ $domains = get_domains($csr_dec); $domain_jsons = array(); for($i=0;$iget_nonce($config['ca_url'])), /* Payload */ array('resource'=>'new-authz','identifier'=>array('type'=>'dns','value'=>$domains[$i])))); } $ca_urls = get_urls($config['ca_url']); /* try to register account */ if($config['account_do_register']) { $status = 1; $reg_result = json_post($ca_urls['new-reg'],$account_json,true,true,$status); if((isset($reg_result['status']) && $reg_result['status']!=$status) || ($status!=201 && $status!=409)) { print_r($reg_result); die2('Error while registering account: '.$status); } } /* Handle challenges */ $nonce = 'n'; for($i=0;$i$nonce), /* Payload */ array('resource'=>'challenge','keyAuthorization'=>$challenge)); $challenge_result = json_post($url,$challenge_json,true,true,$status,$nonce); for($j=0;$j<$config['challenge_retry_n'];$j++) { sleep($config['challenge_retry_wait']); $cu = curl_init($url); curl_setopt($cu,CURLOPT_RETURNTRANSFER,true); $result = curl_exec($cu); $challenge_result = json_decode($result,true,6); if($challenge_result==false) die2('Could not parse json answer from server: '.$result); if(!isset($challenge_result['status'])) { print_r($challenge_result); die2('Json answer from server contains no status'); } if($challenge_result['status']=='valid') break; if($challenge_result['status']!='pending') { print_r($challenge_result); die2('Json answer from server contains unknown status'); } } unlink($challenge_file); $challenge_file=''; if($challenge_result['status']!='valid') die2('Timeout while waiting for domain: '.$domains[$i]); } json_sign(NULL,NULL,NULL); // delete private key from memory $status = 1; $cert_result = json_post($ca_urls['new-cert'],$csr_json,false,true,$status); if($status==201) { $final_cert = "-----BEGIN CERTIFICATE-----\r\n".chunk_split(base64_encode($cert_result), 64, "\r\n")."-----END CERTIFICATE-----\r\n"; $openssl_cert = openssl_x509_read($final_cert); if($openssl_cert==false) { die2("Generated cert could not be read:\n".$final_cert); } $cert_info = openssl_x509_parse($openssl_cert); file_put_contents($config['certificate'],$final_cert); $intermediate = file_get_contents($config['intermediate_cert']); for($i=0;$i1) { mail($config['info_mail'],'ACME new cert',ob_get_contents(),implode("\r\n",$config['info_mail_header_fields']),'-oi -f '.$config['info_mail_from']); } ob_end_flush(); exit(0); } else { $json_result = json_decode($cert_result,true,6); if($json_result==false) die2('Could not parse json answer from server: '.$cert_result); print_r($json_result); die2('Could not get certificate.'); } /********* Functions start here *********/ function json_post($url,$json,$decode=true,$head=false,&$status=NULL,&$nonce=NULL) { $cu = curl_init($url); curl_setopt($cu,CURLOPT_CUSTOMREQUEST,'POST'); curl_setopt($cu,CURLOPT_POSTFIELDS,$json); curl_setopt($cu,CURLOPT_RETURNTRANSFER,true); if($head) curl_setopt($cu,CURLOPT_HEADER,true); curl_setopt($cu,CURLOPT_SSL_VERIFYPEER,true); curl_setopt($cu,CURLOPT_HTTPHEADER,array( 'Content-Type: application/json', 'Content-Length: '.strlen($json)) ); $result = curl_exec($cu); if($result==false) die2('Could not post json to server'); if($head) { $result_array = explode("\r\n\r\n",$result); $head_cnt = count($result_array); if($head_cnt>1) $head_cnt--; for($j=0;$j<$head_cnt;$j++) { $headers = explode("\r\n", $result_array[$j]); if($status!=NULL) { $status_a = explode(' ',$headers[0]); $status = $status_a[1]; } if($nonce!=NULL) for($k=1;$k$head,'protected'=>base64url_encode(json_encode($protected)),'payload'=>base64url_encode(json_encode($payload,JSON_UNESCAPED_SLASHES))); $signature = ''; if(!openssl_sign($json_a['protected'].'.'.$json_a['payload'],$signature,$openssl_private_key,OPENSSL_ALGO_SHA256)) { openssl_free_key($openssl_private_key); die2('Cannot sign: unknown problem'); } $json_a['signature'] = base64url_encode($signature); return json_encode($json_a); } function base64url_encode($data) { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } // We need to parse CSR ourself... all this code just extracts the domains from the CSR: function get_domains($csr_dec) { $i=0; $domains = array(); while ($i<strlen($csr_dec)) { if(ord($csr_dec[$i])==0x30 || ord($csr_dec[$i])==0x31 || ord($csr_dec[$i])==0xa0) { // sequenze or set or cont(?) $i++; if(ord($csr_dec[$i])==0x80) die2('CSR with indefinite length elements'); if((ord($csr_dec[$i])&0x80)==0x80) $i+=(ord($csr_dec[$i])&0x7f); $i++; continue; } if(ord($csr_dec[$i])==0x06) { // object if(ord($csr_dec[$i+1])==0x3 && ord($csr_dec[$i+2])==0x55 && ord($csr_dec[$i+3])==0x4 && ord($csr_dec[$i+4])==0x3) { // common name if(ord($csr_dec[$i+5])==0x13) { // string $i+=6; $l=0; if(ord($csr_dec[$i])==0x80) die2('domain with indefinite length'); if((ord($csr_dec[$i])&0x80)==0x80) { for($j=1;$j<=(ord($csr_dec[$i])&0x7f);$j++) $l+=(ord($csr_dec[$i+$j])<<(8*((ord($csr_dec[$i])&0x7f)-$j))); $i+=(ord($csr_dec[$i])&0x7f); } else $l=ord($csr_dec[$i]); $i++; array_push($domains,substr($csr_dec,$i,$l)); $i+=$l; continue; } } else if(ord($csr_dec[$i+1])==0x3 && ord($csr_dec[$i+2])==0x55 && ord($csr_dec[$i+3])==0x1d && ord($csr_dec[$i+4])==0x11) { // san if(ord($csr_dec[$i+5])==0x04) { // octet string $i+=6; $l=0; if(ord($csr_dec[$i])==0x80) die2('san with indefinite length'); if((ord($csr_dec[$i])&0x80)==0x80) { for($j=1;$j<=(ord($csr_dec[$i])&0x7f);$j++) $l+=(ord($csr_dec[$i+$j])<<(8*((ord($csr_dec[$i])&0x7f)-$j))); $i+=(ord($csr_dec[$i])&0x7f); } else $l=ord($csr_dec[$i]); $i++; $j=1; if((ord($csr_dec[$i+$j])+2)!=$l) die2('do not understand san'); $j++; while($j<$l) { if (ord($csr_dec[$i+$j])==0x82) { // dns entry ? array_push($domains,substr($csr_dec,($i+$j+2),ord($csr_dec[$i+$j+1]))); } $j+=(ord($csr_dec[$i+$j+1])+2); } $i+=$l; continue; } } } if(ord($csr_dec[$i+1])==0x80) die2('CSR with indefinite length elements'); if((ord($csr_dec[$i+1])&0x80)==0x80) { $i++; $l=(ord($csr_dec[$i])&0x7f); for($j=1;$j<=(ord($csr_dec[$i])&0x7f);$j++) $l+=(ord($csr_dec[$i+$j])<0) { mail($config['info_mail'],'ACME cert error',$str."\n".ob_get_contents(),implode("\r\n",$config['info_mail_header_fields']),'-oi -f '.$config['info_mail_from']); } ob_end_flush(); if($challenge_file!='') unlink($challenge_file); json_sign(NULL,NULL,NULL); echo $str; die(-1); } ?>[/code]
4 Likes

Thank you! Very helpful!

Looks interesting - any chance of a “dns-01” challenge version of this? … or what part of the code would I have to look at changing? Please assume I can provide a function to add a TXT record to a Domain Zone, and another to Delete a TXT record…

I like this because I can also drop the Certificates into a MySQL Database (where I keep them at the moment). I also already create a CSR and self signed CRT and allow/manage multiple instances of Certificates. From here, I also generate the appropriate TLSA records and stick them in the appropriate DNS zone for that Domain.

I would assume that as someone requests an SSL certificate for their domain, I create the CSR and then ask “Lets Encrypt” for the key to put in my DNS. I stick it in the DNS. (Button One)

There can then be a five minute wait before the DNS is written to disk and AXFR-ed to all authoritative zones. “Button-Two” will become Active when this has happened - which can then fetch the new CRT from “Lets Encrypt”, or rather - check the TXT token is everywhere, Ask “Lets Encrypt” to look for the Challenge in the DNS, on OK, to then accept the new CRT and stick it in my System.

I also like this because it looks like I am controlling the creation of the CSR - which makes management of TLSA’s so much simpler.

I’m also interested in automating dns challenge, have you find any solution?

I gave up and now use dehydrated with dns-01 challenge. In order to get around the fact that my DNS sync’s only once every five minutes, I edited the “hooks.sh” script so that after adding the needed DNS records (via MySQL) I then loop around, sleeping 60 seconds until the Data appears with a “dig”. I can then proceed. Pro: It works. Con: Only one “dehydrate” process can run at any one time - so all others are blocked. Not a problem for now but limits me to about 288 requests a day.
Probably need to move from BIND to POWERDNS?
A PHP version of Dehydrated (with dns-01 challenge) would be useful too.