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]