├── ACMECert.php ├── alpn_responder.js ├── composer.json ├── LICENSE.md ├── src ├── ACME_Exception.php ├── ACMEv2.php └── ACMECert.php └── README.md /ACMECert.php: -------------------------------------------------------------------------------- 1 | =5.6.0", 18 | "ext-openssl": "*" 19 | }, 20 | "suggest": { 21 | "ext-curl": "Optional for better http performance" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "skoerfgen\\ACMECert\\": "src" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Stefan Körfgen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/ACME_Exception.php: -------------------------------------------------------------------------------- 1 | type=$type; 37 | $this->subproblems=$subproblems; 38 | parent::__construct($detail.' ('.$type.')'); 39 | } 40 | function getType(){ 41 | return $this->type; 42 | } 43 | function getSubproblems(){ 44 | return $this->subproblems; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/ACMEv2.php: -------------------------------------------------------------------------------- 1 | 'https://acme-v02.api.letsencrypt.org/directory', 39 | 'staging'=>'https://acme-staging-v02.api.letsencrypt.org/directory' 40 | ),$ch=null,$logger=true,$bits,$sha_bits,$directory,$resources,$jwk_header,$kid_header,$account_key,$thumbprint,$nonce=null,$delay_until=null; 41 | 42 | public function __construct($live=true){ 43 | if (is_bool($live)){ // backwards compatibility to ACMECert v3.1.2 or older 44 | $this->directory=$this->directories[$live?'live':'staging']; 45 | }else{ 46 | $this->directory=$live; 47 | } 48 | } 49 | 50 | public function __destruct(){ 51 | if (PHP_MAJOR_VERSION<8 && $this->account_key) openssl_pkey_free($this->account_key); 52 | if ($this->ch) curl_close($this->ch); 53 | } 54 | 55 | public function loadAccountKey($account_key_pem){ 56 | if (PHP_MAJOR_VERSION<8 && $this->account_key) openssl_pkey_free($this->account_key); 57 | if (false===($this->account_key=openssl_pkey_get_private($account_key_pem))){ 58 | throw new Exception('Could not load account key: '.$account_key_pem.' ('.$this->get_openssl_error().')'); 59 | } 60 | 61 | if (false===($details=openssl_pkey_get_details($this->account_key))){ 62 | throw new Exception('Could not get account key details: '.$account_key_pem.' ('.$this->get_openssl_error().')'); 63 | } 64 | 65 | $this->bits=$details['bits']; 66 | switch($details['type']){ 67 | case OPENSSL_KEYTYPE_EC: 68 | if (version_compare(PHP_VERSION,'7.1.0')<0) throw new Exception('PHP >= 7.1.0 required for EC keys !'); 69 | $this->sha_bits=($this->bits==521?512:$this->bits); 70 | $this->jwk_header=array( // JOSE Header - RFC7515 71 | 'alg'=>'ES'.$this->sha_bits, 72 | 'jwk'=>array( // JSON Web Key 73 | 'crv'=>'P-'.$details['bits'], 74 | 'kty'=>'EC', 75 | 'x'=>$this->base64url(str_pad($details['ec']['x'],ceil($this->bits/8),"\x00",STR_PAD_LEFT)), 76 | 'y'=>$this->base64url(str_pad($details['ec']['y'],ceil($this->bits/8),"\x00",STR_PAD_LEFT)) 77 | ) 78 | ); 79 | break; 80 | case OPENSSL_KEYTYPE_RSA: 81 | $this->sha_bits=256; 82 | $this->jwk_header=array( // JOSE Header - RFC7515 83 | 'alg'=>'RS256', 84 | 'jwk'=>array( // JSON Web Key 85 | 'e'=>$this->base64url($details['rsa']['e']), // public exponent 86 | 'kty'=>'RSA', 87 | 'n'=>$this->base64url($details['rsa']['n']) // public modulus 88 | ) 89 | ); 90 | break; 91 | default: 92 | throw new Exception('Unsupported key type! Must be RSA or EC key.'); 93 | break; 94 | } 95 | 96 | $this->kid_header=array( 97 | 'alg'=>$this->jwk_header['alg'], 98 | 'kid'=>null 99 | ); 100 | 101 | $this->thumbprint=$this->base64url( // JSON Web Key (JWK) Thumbprint - RFC7638 102 | hash( 103 | 'sha256', 104 | json_encode($this->jwk_header['jwk']), 105 | true 106 | ) 107 | ); 108 | } 109 | 110 | public function getAccountID(){ 111 | if (!$this->kid_header['kid']) self::getAccount(); 112 | return $this->kid_header['kid']; 113 | } 114 | 115 | public function setLogger($value=true){ 116 | switch(true){ 117 | case is_bool($value): 118 | break; 119 | case is_callable($value): 120 | break; 121 | default: 122 | throw new Exception('setLogger: invalid value provided'); 123 | break; 124 | } 125 | $this->logger=$value; 126 | } 127 | 128 | public function log($txt){ 129 | switch(true){ 130 | case $this->logger===true: 131 | error_log($txt); 132 | break; 133 | case $this->logger===false: 134 | break; 135 | default: 136 | $fn=$this->logger; 137 | $fn($txt); 138 | break; 139 | } 140 | } 141 | 142 | protected function create_ACME_Exception($type,$detail,$subproblems=array()){ 143 | $this->log('ACME_Exception: '.$detail.' ('.$type.')'); 144 | return new ACME_Exception($type,$detail,$subproblems); 145 | } 146 | 147 | protected function get_openssl_error(){ 148 | $out=array(); 149 | $arr=error_get_last(); 150 | if (is_array($arr)){ 151 | $out[]=$arr['message']; 152 | } 153 | $out[]=openssl_error_string(); 154 | return implode(' | ',$out); 155 | } 156 | 157 | protected function getAccount(){ 158 | $this->log('Getting account info'); 159 | $ret=$this->request('newAccount',array('onlyReturnExisting'=>true)); 160 | $this->log('Account info retrieved'); 161 | return $ret; 162 | } 163 | 164 | protected function keyAuthorization($token){ 165 | return $token.'.'.$this->thumbprint; 166 | } 167 | 168 | protected function readDirectory(){ 169 | $this->log('Initializing ACME v2 environment: '.$this->directory); 170 | $ret=$this->http_request($this->directory); // Read ACME Directory 171 | if ( 172 | !is_array($ret['body']) || 173 | !empty( 174 | array_diff_key( 175 | array_flip(array('newNonce','newAccount','newOrder')), 176 | $ret['body'] 177 | ) 178 | ) 179 | ){ 180 | throw new Exception('Failed to read directory: '.$this->directory); 181 | } 182 | $this->resources=$ret['body']; // store resources for later use 183 | $this->log('Initialized'); 184 | } 185 | 186 | protected function request($type,$payload='',$retry=false){ 187 | if (!$this->jwk_header) { 188 | throw new Exception('use loadAccountKey to load an account key'); 189 | } 190 | 191 | if (!$this->resources) $this->readDirectory(); 192 | 193 | if (0===stripos($type,'http')) { 194 | $this->resources['_tmp']=$type; 195 | $type='_tmp'; 196 | } 197 | 198 | try { 199 | $ret=$this->http_request($this->resources[$type],json_encode( 200 | $this->jws_encapsulate($type,$payload) 201 | )); 202 | }catch(ACME_Exception $e){ // retry previous request once, if replay-nonce expired/failed 203 | if (!$retry && $e->getType()==='urn:ietf:params:acme:error:badNonce') { 204 | $this->log('Replay-Nonce expired, retrying previous request'); 205 | return $this->request($type,$payload,true); 206 | } 207 | if (!$retry && $e->getType()==='urn:ietf:params:acme:error:rateLimited' && $this->delay_until!==null) { 208 | return $this->request($type,$payload,true); 209 | } 210 | throw $e; // rethrow all other exceptions 211 | } 212 | 213 | if (!$this->kid_header['kid'] && $type==='newAccount'){ 214 | $this->kid_header['kid']=$ret['headers']['location']; 215 | $this->log('AccountID: '.$this->kid_header['kid']); 216 | } 217 | 218 | return $ret; 219 | } 220 | 221 | protected function jws_encapsulate($type,$payload,$is_inner_jws=false){ // RFC7515 222 | if ($type==='newAccount' || $is_inner_jws) { 223 | $protected=$this->jwk_header; 224 | }else{ 225 | $this->getAccountID(); 226 | $protected=$this->kid_header; 227 | } 228 | 229 | if (!$is_inner_jws) { 230 | if (!$this->nonce) { 231 | $ret=$this->http_request($this->resources['newNonce'],false); 232 | } 233 | $protected['nonce']=$this->nonce; 234 | $this->nonce=null; 235 | } 236 | 237 | if (!isset($this->resources[$type])){ 238 | throw new Exception('Resource "'.$type.'" not available.'); 239 | } 240 | 241 | $protected['url']=$this->resources[$type]; 242 | 243 | $protected64=$this->base64url(json_encode($protected,JSON_UNESCAPED_SLASHES)); 244 | $payload64=$this->base64url(is_string($payload)?$payload:json_encode($payload,JSON_UNESCAPED_SLASHES)); 245 | 246 | if (false===openssl_sign( 247 | $protected64.'.'.$payload64, 248 | $signature, 249 | $this->account_key, 250 | 'SHA'.$this->sha_bits 251 | )){ 252 | throw new Exception('Failed to sign payload !'.' ('.$this->get_openssl_error().')'); 253 | } 254 | 255 | return array( 256 | 'protected'=>$protected64, 257 | 'payload'=>$payload64, 258 | 'signature'=>$this->base64url($this->jwk_header['alg'][0]=='R'?$signature:$this->asn2signature($signature,ceil($this->bits/8))) 259 | ); 260 | } 261 | 262 | private function asn2signature($asn,$pad_len){ 263 | if ($asn[0]!=="\x30") throw new Exception('ASN.1 SEQUENCE not found !'); 264 | $asn=substr($asn,$asn[1]==="\x81"?3:2); 265 | if ($asn[0]!=="\x02") throw new Exception('ASN.1 INTEGER 1 not found !'); 266 | $R=ltrim(substr($asn,2,ord($asn[1])),"\x00"); 267 | $asn=substr($asn,ord($asn[1])+2); 268 | if ($asn[0]!=="\x02") throw new Exception('ASN.1 INTEGER 2 not found !'); 269 | $S=ltrim(substr($asn,2,ord($asn[1])),"\x00"); 270 | return str_pad($R,$pad_len,"\x00",STR_PAD_LEFT).str_pad($S,$pad_len,"\x00",STR_PAD_LEFT); 271 | } 272 | 273 | protected function base64url($data){ // RFC7515 - Appendix C 274 | return rtrim(strtr(base64_encode($data),'+/','-_'),'='); 275 | } 276 | 277 | protected function base64url_decode($data){ 278 | return base64_decode(strtr($data,'-_','+/')); 279 | } 280 | 281 | private function json_decode($str){ 282 | $ret=json_decode($str,true); 283 | if ($ret===null) { 284 | throw new Exception('Could not parse JSON: '.$str); 285 | } 286 | return $ret; 287 | } 288 | 289 | protected function http_request($url,$data=null){ 290 | if ($this->ch===null) { 291 | if (extension_loaded('curl') && $this->ch=curl_init()) { 292 | $this->log('Using cURL'); 293 | }elseif(ini_get('allow_url_fopen')){ 294 | $this->ch=false; 295 | $this->log('Using fopen wrappers'); 296 | }else{ 297 | throw new Exception('Can not connect, no cURL or fopen wrappers enabled !'); 298 | } 299 | } 300 | 301 | if ($this->delay_until!==null){ 302 | $delta=$this->delay_until-time(); 303 | if ($delta>0 && $delta<300){ // ignore delay if not in range 1s..5min 304 | $this->log('Delaying '.$delta.'s (rate limit)'); 305 | sleep($delta); 306 | } 307 | $this->delay_until=null; 308 | } 309 | 310 | $method=$data===false?'HEAD':($data===null?'GET':'POST'); 311 | $user_agent='ACMECert v3.7.1 (+https://github.com/skoerfgen/ACMECert)'; 312 | $header=($data===null||$data===false)?array():array('Content-Type: application/jose+json'); 313 | if ($this->ch) { 314 | $headers=array(); 315 | curl_setopt_array($this->ch,array( 316 | CURLOPT_URL=>$url, 317 | CURLOPT_FOLLOWLOCATION=>true, 318 | CURLOPT_RETURNTRANSFER=>true, 319 | CURLOPT_TCP_NODELAY=>true, 320 | CURLOPT_NOBODY=>$data===false, 321 | CURLOPT_USERAGENT=>$user_agent, 322 | CURLOPT_CUSTOMREQUEST=>$method, 323 | CURLOPT_HTTPHEADER=>$header, 324 | CURLOPT_POSTFIELDS=>$data, 325 | CURLOPT_HEADERFUNCTION=>static function($ch,$header)use(&$headers){ 326 | $headers[]=$header; 327 | return strlen($header); 328 | } 329 | )); 330 | $took=microtime(true); 331 | $body=curl_exec($this->ch); 332 | $took=round(microtime(true)-$took,2).'s'; 333 | if ($body===false) throw new Exception('HTTP Request Error: '.curl_error($this->ch)); 334 | }else{ 335 | $opts=array( 336 | 'http'=>array( 337 | 'header'=>$header, 338 | 'method'=>$method, 339 | 'user_agent'=>$user_agent, 340 | 'ignore_errors'=>true, 341 | 'timeout'=>60, 342 | 'content'=>$data 343 | ) 344 | ); 345 | $took=microtime(true); 346 | $body=file_get_contents($url,false,stream_context_create($opts)); 347 | $took=round(microtime(true)-$took,2).'s'; 348 | if ($body===false) throw new Exception('HTTP Request Error: '.$this->get_openssl_error()); 349 | $headers=$http_response_header; 350 | } 351 | 352 | $headers=array_reduce( // parse http response headers into array 353 | array_filter($headers,function($item){ return trim($item)!=''; }), 354 | function($carry,$item)use(&$code){ 355 | $parts=explode(':',$item,2); 356 | if (count($parts)===1){ 357 | list(,$code)=explode(' ',trim($item),3); 358 | $carry=array(); 359 | }else{ 360 | list($k,$v)=$parts; 361 | $k=strtolower(trim($k)); 362 | switch($k){ 363 | case 'link': 364 | if (preg_match('/<(.*)>\s*;\s*rel=\"(.*)\"/',$v,$matches)){ 365 | $carry[$k][$matches[2]][]=trim($matches[1]); 366 | } 367 | break; 368 | case 'content-type': 369 | list($v)=explode(';',$v,2); 370 | default: 371 | $carry[$k]=trim($v); 372 | break; 373 | } 374 | } 375 | return $carry; 376 | }, 377 | array() 378 | ); 379 | $this->log(' '.$url.' ['.$code.'] ('.$took.')'); 380 | 381 | if (!empty($headers['replay-nonce'])) $this->nonce=$headers['replay-nonce']; 382 | 383 | if (isset($headers['retry-after'])){ 384 | $this->delay_until=$this->parseRetryAfterHeader($headers['retry-after'])+time(); 385 | } 386 | 387 | if (!empty($headers['content-type'])){ 388 | switch($headers['content-type']){ 389 | case 'application/json': 390 | if ($code[0]=='2'){ // on non 2xx response: fall through to problem+json case 391 | $body=$this->json_decode($body); 392 | if (isset($body['error']) && !(isset($body['status']) && $body['status']==='valid')) { 393 | $this->handleError($body['error']); 394 | } 395 | break; 396 | } 397 | case 'application/problem+json': 398 | $body=$this->json_decode($body); 399 | $this->handleError($body); 400 | break; 401 | } 402 | } 403 | 404 | if ($code[0]!='2') { 405 | throw new Exception('Invalid HTTP-Status-Code received: '.$code.': '.print_r($body,true)); 406 | } 407 | 408 | $ret=array( 409 | 'code'=>$code, 410 | 'headers'=>$headers, 411 | 'body'=>$body 412 | ); 413 | 414 | return $ret; 415 | } 416 | 417 | protected function parseRetryAfterHeader($h){ 418 | if (is_numeric($h)){ 419 | return (int)$h; 420 | }else{ 421 | $ts=strtotime($h); 422 | return $ts===false?0:max(0,$ts-time()); 423 | } 424 | } 425 | 426 | private function handleError($error){ 427 | throw $this->create_ACME_Exception($error['type'],$error['detail'], 428 | array_map(function($subproblem){ 429 | return $this->create_ACME_Exception( 430 | $subproblem['type'], 431 | (isset($subproblem['identifier']['value'])? 432 | '"'.$subproblem['identifier']['value'].'": ': 433 | '' 434 | ).$subproblem['detail'] 435 | ); 436 | },isset($error['subproblems'])?$error['subproblems']:array()) 437 | ); 438 | } 439 | 440 | } 441 | -------------------------------------------------------------------------------- /src/ACMECert.php: -------------------------------------------------------------------------------- 1 | _register($termsOfServiceAgreed,$contacts); 41 | } 42 | 43 | public function registerEAB($termsOfServiceAgreed,$eab_kid,$eab_hmac,$contacts=array()){ 44 | if (!$this->resources) $this->readDirectory(); 45 | 46 | $protected=array( 47 | 'alg'=>'HS256', 48 | 'kid'=>$eab_kid, 49 | 'url'=>$this->resources['newAccount'] 50 | ); 51 | $payload=$this->jwk_header['jwk']; 52 | 53 | $protected64=$this->base64url(json_encode($protected,JSON_UNESCAPED_SLASHES)); 54 | $payload64=$this->base64url(json_encode($payload,JSON_UNESCAPED_SLASHES)); 55 | 56 | $signature=hash_hmac('sha256',$protected64.'.'.$payload64,$this->base64url_decode($eab_hmac),true); 57 | 58 | return $this->_register($termsOfServiceAgreed,$contacts,array( 59 | 'externalAccountBinding'=>array( 60 | 'protected'=>$protected64, 61 | 'payload'=>$payload64, 62 | 'signature'=>$this->base64url($signature) 63 | ) 64 | )); 65 | } 66 | 67 | private function _register($termsOfServiceAgreed=false,$contacts=array(),$extra=array()){ 68 | $this->log('Registering account'); 69 | 70 | $ret=$this->request('newAccount',array( 71 | 'termsOfServiceAgreed'=>(bool)$termsOfServiceAgreed, 72 | 'contact'=>$this->make_contacts_array($contacts) 73 | )+$extra); 74 | $this->log($ret['code']==201?'Account registered':'Account already registered'); 75 | return $ret['body']; 76 | } 77 | 78 | public function update($contacts=array()){ 79 | $this->log('Updating account'); 80 | $ret=$this->request($this->getAccountID(),array( 81 | 'contact'=>$this->make_contacts_array($contacts) 82 | )); 83 | $this->log('Account updated'); 84 | return $ret['body']; 85 | } 86 | 87 | public function getAccount(){ 88 | $ret=parent::getAccount(); 89 | return $ret['body']; 90 | } 91 | 92 | public function deactivateAccount(){ 93 | $this->log('Deactivating account'); 94 | $ret=$this->deactivate($this->getAccountID()); 95 | $this->log('Account deactivated'); 96 | return $ret; 97 | } 98 | 99 | public function deactivate($url){ 100 | $this->log('Deactivating resource: '.$url); 101 | $ret=$this->request($url,array('status'=>'deactivated')); 102 | $this->log('Resource deactivated'); 103 | return $ret['body']; 104 | } 105 | 106 | public function getTermsURL(){ 107 | if (!$this->resources) $this->readDirectory(); 108 | if (!isset($this->resources['meta']['termsOfService'])){ 109 | throw new Exception('Failed to get Terms Of Service URL'); 110 | } 111 | return $this->resources['meta']['termsOfService']; 112 | } 113 | 114 | public function getCAAIdentities(){ 115 | if (!$this->resources) $this->readDirectory(); 116 | if (!isset($this->resources['meta']['caaIdentities'])){ 117 | throw new Exception('Failed to get CAA Identities'); 118 | } 119 | return $this->resources['meta']['caaIdentities']; 120 | } 121 | 122 | public function keyChange($new_account_key_pem){ // account key roll-over 123 | $ac2=new ACMEv2(); 124 | $ac2->loadAccountKey($new_account_key_pem); 125 | $account=$this->getAccountID(); 126 | $ac2->resources=$this->resources; 127 | 128 | $this->log('Account Key Roll-Over'); 129 | 130 | $ret=$this->request('keyChange', 131 | $ac2->jws_encapsulate('keyChange',array( 132 | 'account'=>$account, 133 | 'oldKey'=>$this->jwk_header['jwk'] 134 | ),true) 135 | ); 136 | $this->log('Account Key Roll-Over successful'); 137 | 138 | $this->loadAccountKey($new_account_key_pem); 139 | return $ret['body']; 140 | } 141 | 142 | public function revoke($pem){ 143 | if (false===($res=openssl_x509_read($pem))){ 144 | throw new Exception('Could not load certificate: '.$pem.' ('.$this->get_openssl_error().')'); 145 | } 146 | if (false===(openssl_x509_export($res,$certificate))){ 147 | throw new Exception('Could not export certificate: '.$pem.' ('.$this->get_openssl_error().')'); 148 | } 149 | 150 | $this->log('Revoking certificate'); 151 | $this->request('revokeCert',array( 152 | 'certificate'=>$this->base64url($this->pem2der($certificate)) 153 | )); 154 | $this->log('Certificate revoked'); 155 | } 156 | 157 | public function getCertificateChain($pem,$domain_config,$callback,$settings=array()){ 158 | $settings=$this->parseSettings($settings); 159 | 160 | $domain_config=array_change_key_case($domain_config,CASE_LOWER); 161 | $domains=array_keys($domain_config); 162 | $authz_deactivated=false; 163 | 164 | $this->getAccountID(); // get account info upfront to avoid mixed up logging order 165 | 166 | // === Order === 167 | $this->log('Creating Order'); 168 | $ret=$this->request('newOrder',$this->makeOrder($domains,$settings)); 169 | $order=$ret['body']; 170 | $order_location=$ret['headers']['location']; 171 | $this->log('Order created: '.$order_location); 172 | 173 | // === Authorization === 174 | if ($order['status']==='ready' && $settings['authz_reuse']) { 175 | $this->log('All authorizations already valid, skipping validation altogether'); 176 | }else{ 177 | $groups=array(); 178 | $auth_count=count($order['authorizations']); 179 | 180 | foreach($order['authorizations'] as $idx=>$auth_url){ 181 | $this->log('Fetching authorization '.($idx+1).' of '.$auth_count); 182 | $ret=$this->request($auth_url,''); 183 | $authorization=$ret['body']; 184 | 185 | // wildcard authorization identifiers have no leading *. 186 | $domain=( // get domain and add leading *. if wildcard is used 187 | isset($authorization['wildcard']) && 188 | $authorization['wildcard'] ? 189 | '*.':'' 190 | ).$authorization['identifier']['value']; 191 | 192 | if ($authorization['status']==='valid') { 193 | if ($settings['authz_reuse']) { 194 | $this->log('Authorization of '.$domain.' already valid, skipping validation'); 195 | }else{ 196 | $this->log('Authorization of '.$domain.' already valid, deactivating authorization'); 197 | $this->deactivate($auth_url); 198 | $authz_deactivated=true; 199 | } 200 | continue; 201 | } 202 | 203 | // groups are used to be able to set more than one TXT Record for one subdomain 204 | // when using dns-01 before firing the validation to avoid DNS caching problem 205 | $groups[ 206 | $domain_config[$domain]['challenge']. 207 | '|'. 208 | (($settings['group'])?ltrim($domain,'*.'):$domain) 209 | ][$domain]=array($auth_url,$authorization); 210 | } 211 | 212 | if ($authz_deactivated){ 213 | $this->log('Restarting Order after deactivating already valid authorizations'); 214 | $settings['authz_reuse']=true; 215 | return $this->getCertificateChain($pem,$domain_config,$callback,$settings); 216 | } 217 | 218 | // make sure dns-01 comes last to avoid DNS problems for other challenges 219 | krsort($groups); 220 | 221 | foreach($groups as $group){ 222 | $pending_challenges=array(); 223 | 224 | try { // make sure that pending challenges are cleaned up in case of failure 225 | foreach($group as $domain=>$arr){ 226 | list($auth_url,$authorization)=$arr; 227 | 228 | $config=$domain_config[$domain]; 229 | $type=$config['challenge']; 230 | 231 | $challenge=$this->parse_challenges($authorization,$type,$challenge_url); 232 | 233 | $opts=array( 234 | 'domain'=>$domain, 235 | 'config'=>$config 236 | ); 237 | list($opts['key'],$opts['value'])=$challenge; 238 | 239 | $this->log('Triggering challenge callback for '.$domain.' using '.$type); 240 | $remove_cb=$callback($opts); 241 | 242 | $pending_challenges[]=array($remove_cb,$opts,$challenge_url,$auth_url); 243 | } 244 | 245 | foreach($pending_challenges as $arr){ 246 | list($remove_cb,$opts,$challenge_url,$auth_url)=$arr; 247 | $this->log('Notifying server for validation of '.$opts['domain']); 248 | $this->request($challenge_url,new stdClass); 249 | 250 | $this->log('Waiting for server challenge validation'); 251 | sleep(1); 252 | 253 | if (!$this->poll('pending',$auth_url,$body)) { 254 | $this->log('Validation failed: '.$opts['domain']); 255 | 256 | $error=$body['challenges'][0]['error']; 257 | throw $this->create_ACME_Exception( 258 | $error['type'], 259 | 'Challenge validation failed: '.$error['detail'] 260 | ); 261 | }else{ 262 | $this->log('Validation successful: '.$opts['domain']); 263 | } 264 | } 265 | 266 | }finally{ // cleanup pending challenges 267 | foreach($pending_challenges as $arr){ 268 | list($remove_cb,$opts)=$arr; 269 | if ($remove_cb) { 270 | $this->log('Triggering remove callback for '.$opts['domain']); 271 | $remove_cb($opts); 272 | } 273 | } 274 | } 275 | } 276 | } 277 | 278 | // autodetect if Private Key or CSR is used 279 | if ($key=openssl_pkey_get_private($pem)){ // Private Key detected 280 | if (PHP_MAJOR_VERSION<8) openssl_free_key($key); 281 | $this->log('Generating CSR'); 282 | $csr=$this->generateCSR($pem,$domains); 283 | }elseif(openssl_csr_get_subject($pem)){ // CSR detected 284 | $this->log('Using provided CSR'); 285 | if (0===strpos($pem,'file://')) { 286 | $csr=file_get_contents(substr($pem,7)); 287 | if (false===$csr) { 288 | throw new Exception('Failed to read CSR from '.$pem.' ('.$this->get_openssl_error().')'); 289 | } 290 | }else{ 291 | $csr=$pem; 292 | } 293 | }else{ 294 | throw new Exception('Could not load Private Key or CSR ('.$this->get_openssl_error().'): '.$pem); 295 | } 296 | 297 | $this->log('Finalizing Order'); 298 | 299 | $ret=$this->request($order['finalize'],array( 300 | 'csr'=>$this->base64url($this->pem2der($csr)) 301 | )); 302 | $ret=$ret['body']; 303 | 304 | if (isset($ret['certificate'])) { 305 | return $this->request_certificate($ret); 306 | } 307 | 308 | if ($this->poll('processing',$order_location,$ret)) { 309 | return $this->request_certificate($ret); 310 | } 311 | 312 | throw new Exception('Order failed'); 313 | } 314 | 315 | public function getCertificateChains($pem,$domain_config,$callback,$settings=array()){ 316 | $default_chain=$this->getCertificateChain($pem,$domain_config,$callback,$settings); 317 | 318 | $out=array(); 319 | $out[$this->getTopIssuerCN($default_chain)]=$default_chain; 320 | 321 | foreach($this->alternate_chains as $link){ 322 | $chain=$this->request_certificate(array('certificate'=>$link),true); 323 | $out[$this->getTopIssuerCN($chain)]=$chain; 324 | } 325 | 326 | $this->log('Received '.count($out).' chain(s): '.implode(', ',array_keys($out))); 327 | return $out; 328 | } 329 | 330 | public function generateCSR($domain_key_pem,$domains){ 331 | if (false===($domain_key=openssl_pkey_get_private($domain_key_pem))){ 332 | throw new Exception('Could not load domain key: '.$domain_key_pem.' ('.$this->get_openssl_error().')'); 333 | } 334 | 335 | $fn=$this->tmp_ssl_cnf($domains); 336 | $cn=reset($domains); 337 | $dn=array(); 338 | if (!filter_var($cn,FILTER_VALIDATE_IP) && strlen($cn)<=64){ 339 | $dn['commonName']=$cn; 340 | } 341 | $csr=openssl_csr_new($dn,$domain_key,array( 342 | 'config'=>$fn, 343 | 'req_extensions'=>'SAN', 344 | 'digest_alg'=>'sha512' 345 | )); 346 | unlink($fn); 347 | if (PHP_MAJOR_VERSION<8) openssl_free_key($domain_key); 348 | 349 | if (false===$csr) { 350 | throw new Exception('Could not generate CSR ! ('.$this->get_openssl_error().')'); 351 | } 352 | if (false===openssl_csr_export($csr,$out)){ 353 | throw new Exception('Could not export CSR ! ('.$this->get_openssl_error().')'); 354 | } 355 | 356 | return $out; 357 | } 358 | 359 | private function generateKey($opts){ 360 | $fn=$this->tmp_ssl_cnf(); 361 | $config=array('config'=>$fn)+$opts; 362 | if (false===($key=openssl_pkey_new($config))){ 363 | throw new Exception('Could not generate new private key ! ('.$this->get_openssl_error().')'); 364 | } 365 | if (false===openssl_pkey_export($key,$pem,null,$config)){ 366 | throw new Exception('Could not export private key ! ('.$this->get_openssl_error().')'); 367 | } 368 | unlink($fn); 369 | if (PHP_MAJOR_VERSION<8) openssl_free_key($key); 370 | return $pem; 371 | } 372 | 373 | public function generateRSAKey($bits=2048){ 374 | return $this->generateKey(array( 375 | 'private_key_bits'=>(int)$bits, 376 | 'private_key_type'=>OPENSSL_KEYTYPE_RSA 377 | )); 378 | } 379 | 380 | public function generateECKey($curve_name='P-384'){ 381 | if (version_compare(PHP_VERSION,'7.1.0')<0) throw new Exception('PHP >= 7.1.0 required for EC keys !'); 382 | $map=array('P-256'=>'prime256v1','P-384'=>'secp384r1','P-521'=>'secp521r1'); 383 | if (isset($map[$curve_name])) $curve_name=$map[$curve_name]; 384 | return $this->generateKey(array( 385 | 'curve_name'=>$curve_name, 386 | 'private_key_type'=>OPENSSL_KEYTYPE_EC 387 | )); 388 | } 389 | 390 | public function parseCertificate($cert_pem){ 391 | if (false===($ret=openssl_x509_read($cert_pem))) { 392 | throw new Exception('Could not load certificate: '.$cert_pem.' ('.$this->get_openssl_error().')'); 393 | } 394 | if (!is_array($ret=openssl_x509_parse($ret,true))) { 395 | throw new Exception('Could not parse certificate ('.$this->get_openssl_error().')'); 396 | } 397 | return $ret; 398 | } 399 | 400 | public function getSAN($pem){ 401 | $ret=$this->parseCertificate($pem); 402 | if (!isset($ret['extensions']['subjectAltName'])){ 403 | throw new Exception('No Subject Alternative Name (SAN) found in certificate'); 404 | } 405 | $out=array(); 406 | foreach(explode(',',$ret['extensions']['subjectAltName']) as $line){ 407 | list($type,$name)=array_map('trim',explode(':',$line,2)); 408 | if ($type==='DNS' || $type==='IP Address'){ 409 | $out[]=$name; 410 | } 411 | } 412 | return $out; 413 | } 414 | 415 | public function getRemainingDays($cert_pem){ 416 | $ret=$this->parseCertificate($cert_pem); 417 | return ($ret['validTo_time_t']-time())/86400; 418 | } 419 | 420 | public function getRemainingPercent($cert_pem){ 421 | $ret=$this->parseCertificate($cert_pem); 422 | $total=$ret['validTo_time_t']-$ret['validFrom_time_t']; 423 | $used=time()-$ret['validFrom_time_t']; 424 | return (1-max(0,min(1,$used/$total)))*100; 425 | } 426 | 427 | public function generateALPNCertificate($domain_key_pem,$domain,$token){ 428 | $domains=array($domain); 429 | $csr=$this->generateCSR($domain_key_pem,$domains); 430 | 431 | $fn=$this->tmp_ssl_cnf($domains,'1.3.6.1.5.5.7.1.31=critical,DER:0420'.$token."\n"); 432 | $config=array( 433 | 'config'=>$fn, 434 | 'x509_extensions'=>'SAN', 435 | 'digest_alg'=>'sha512' 436 | ); 437 | $cert=openssl_csr_sign($csr,null,$domain_key_pem,1,$config); 438 | unlink($fn); 439 | if (false===$cert) { 440 | throw new Exception('Could not generate self signed certificate ! ('.$this->get_openssl_error().')'); 441 | } 442 | if (false===openssl_x509_export($cert,$out)){ 443 | throw new Exception('Could not export self signed certificate ! ('.$this->get_openssl_error().')'); 444 | } 445 | return $out; 446 | } 447 | 448 | public function getProfiles(){ 449 | if (!$this->resources) $this->readDirectory(); 450 | if ( 451 | !isset($this->resources['meta']['profiles']) || 452 | !is_array($this->resources['meta']['profiles'])) 453 | { 454 | throw new Exception('certificate profiles not supported by CA'); 455 | } 456 | return $this->resources['meta']['profiles']; 457 | } 458 | 459 | private function requireARI(){ 460 | if (!$this->resources) $this->readDirectory(); 461 | if (!isset($this->resources['renewalInfo'])) throw new Exception('ARI not supported by CA'); 462 | } 463 | 464 | public function getARI($pem,&$ari_cert_id=null){ 465 | $ari_cert_id=null; 466 | 467 | $id=$this->getARICertID($pem); 468 | $this->requireARI(); 469 | 470 | $this->log('Requesting ACME Renewal Information'); 471 | $ret=$this->http_request($this->resources['renewalInfo'].'/'.$id); 472 | $this->delay_until=null; 473 | 474 | if (!is_array($ret['body']['suggestedWindow'])) throw new Exception('ARI suggestedWindow not present'); 475 | 476 | $sw=&$ret['body']['suggestedWindow']; 477 | 478 | if (!isset($sw['start'])) throw new Exception('ARI suggestedWindow start not present'); 479 | if (!isset($sw['end'])) throw new Exception('ARI suggestedWindow end not present'); 480 | 481 | $sw=array_map(array($this,'parseDate'),$sw); 482 | 483 | $out=$ret['body']; 484 | 485 | if (isset($out['explanationURL'])){ 486 | if (trim($out['explanationURL'])===''){ 487 | unset($out['explanationURL']); 488 | } 489 | } 490 | 491 | if (isset($ret['headers']['retry-after'])){ 492 | $tmp=$this->parseRetryAfterHeader($ret['headers']['retry-after']); 493 | if ($tmp>0){ 494 | $out['retry_after']=$tmp; 495 | } 496 | } 497 | 498 | $out['ari_cert_id']=$id; 499 | $ari_cert_id=$id; 500 | return $out; 501 | } 502 | 503 | private function getARICertID($pem){ 504 | if (version_compare(PHP_VERSION,'7.1.2','<')){ 505 | throw new Exception('PHP Version >= 7.1.2 required for ARI'); // serialNumberHex - https://github.com/php/php-src/pull/1755 506 | } 507 | 508 | $ret=$this->parseCertificate($pem); 509 | 510 | if (!isset($ret['extensions']['authorityKeyIdentifier'])) { 511 | throw new Exception('authorityKeyIdentifier missing'); 512 | } 513 | 514 | $aki=trim($ret['extensions']['authorityKeyIdentifier']); 515 | if (stripos($aki,'keyid')===0) $aki=substr($aki,5); 516 | $aki=hex2bin(str_replace(':','',$aki)); 517 | if (!$aki) throw new Exception('Failed to parse authorityKeyIdentifier'); 518 | 519 | if (!isset($ret['serialNumberHex'])) { 520 | throw new Exception('serialNumberHex missing'); 521 | } 522 | $ser=hex2bin(trim($ret['serialNumberHex'])); 523 | if (!$ser) throw new Exception('Failed to parse serialNumberHex'); 524 | if (ord($ser[0]) & 0x80) $ser="\x00".$ser; 525 | 526 | return $this->base64url($aki).'.'.$this->base64url($ser); 527 | } 528 | 529 | private function parseDate($str){ 530 | $ret=strtotime(preg_replace('/(\.\d\d)\d+/','$1',$str)); 531 | if ($ret===false) throw new Exception('Failed to parse date: '.$str); 532 | return $ret; 533 | } 534 | 535 | private function parseSettings($opts){ 536 | // authz_reuse: backwards compatibility to ACMECert v3.1.2 or older 537 | if (!is_array($opts)) $opts=array('authz_reuse'=>(bool)$opts); 538 | if (!isset($opts['authz_reuse'])) $opts['authz_reuse']=true; 539 | if (!isset($opts['group'])) $opts['group']=true; 540 | 541 | $diff=array_diff_key( 542 | $opts, 543 | array_flip(array('authz_reuse','notAfter','notBefore','replaces','profile','group')) 544 | ); 545 | 546 | if (!empty($diff)){ 547 | throw new Exception('getCertificateChain(s): Invalid option "'.key($diff).'"'); 548 | } 549 | 550 | return $opts; 551 | } 552 | 553 | private function setRFC3339Date(&$out,$key,$opts){ 554 | if (isset($opts[$key])){ 555 | $out[$key]=is_string($opts[$key])? 556 | $opts[$key]: 557 | date(DATE_RFC3339,$opts[$key]); 558 | } 559 | } 560 | 561 | private function makeOrder($domains,$opts){ 562 | $order=array( 563 | 'identifiers'=>array_map( 564 | function($domain){ 565 | if (filter_var($domain,FILTER_VALIDATE_IP)){ 566 | return array('type'=>'ip','value'=>$domain); 567 | }else{ 568 | return array('type'=>'dns','value'=>$domain); 569 | } 570 | }, 571 | $domains 572 | ) 573 | ); 574 | $this->setRFC3339Date($order,'notAfter',$opts); 575 | $this->setRFC3339Date($order,'notBefore',$opts); 576 | 577 | if (isset($opts['replaces'])) { // ARI 578 | $this->requireARI(); 579 | $order['replaces']=$opts['replaces']; 580 | $this->log('Replacing Certificate: '.$opts['replaces']); 581 | } 582 | 583 | if (isset($opts['profile'])) { // certificate profiles 584 | $profiles=$this->getProfiles(); 585 | 586 | if (!isset($profiles[$opts['profile']])) { 587 | throw new Exception('certificate profile "'.$opts['profile'].'" not supported by CA'); 588 | } 589 | 590 | $order['profile']=$opts['profile']; 591 | $this->log('Selected certificate profile: '.$opts['profile']); 592 | } 593 | 594 | return $order; 595 | } 596 | 597 | private function parse_challenges($authorization,$type,&$url){ 598 | foreach($authorization['challenges'] as $challenge){ 599 | if ($challenge['type']!=$type) continue; 600 | 601 | $url=$challenge['url']; 602 | 603 | switch($challenge['type']){ 604 | case 'dns-01': 605 | return array( 606 | '_acme-challenge.'.$authorization['identifier']['value'], 607 | $this->base64url(hash('sha256',$this->keyAuthorization($challenge['token']),true)) 608 | ); 609 | break; 610 | case 'http-01': 611 | return array( 612 | '/.well-known/acme-challenge/'.$challenge['token'], 613 | $this->keyAuthorization($challenge['token']) 614 | ); 615 | break; 616 | case 'tls-alpn-01': 617 | return array(null,hash('sha256',$this->keyAuthorization($challenge['token']))); 618 | break; 619 | } 620 | } 621 | throw new Exception( 622 | 'Challenge type: "'.$type.'" not available, for this challenge use '. 623 | implode(' or ',array_map( 624 | function($a){ 625 | return '"'.$a['type'].'"'; 626 | }, 627 | $authorization['challenges'] 628 | )) 629 | ); 630 | } 631 | 632 | private function poll($initial,$type,&$ret){ 633 | $max_tries=10; // ~ 5 minutes 634 | for($i=0;$i<$max_tries;$i++){ 635 | $ret=$this->request($type); 636 | $ret=$ret['body']; 637 | if ($ret['status']!==$initial) return $ret['status']==='valid'; 638 | $s=pow(2,min($i,6)); 639 | if ($i!==$max_tries-1){ 640 | $this->log('Retrying in '.($s).'s'); 641 | sleep($s); 642 | } 643 | } 644 | throw new Exception('Aborted after '.$max_tries.' tries'); 645 | } 646 | 647 | private function request_certificate($ret,$alternate=false){ 648 | $this->log('Requesting '.($alternate?'alternate':'default').' certificate-chain'); 649 | $ret=$this->request($ret['certificate'],''); 650 | if ($ret['headers']['content-type']!=='application/pem-certificate-chain'){ 651 | throw new Exception('Unexpected content-type: '.$ret['headers']['content-type']); 652 | } 653 | 654 | $chain=array(); 655 | foreach($this->splitChain($ret['body']) as $cert){ 656 | $info=$this->parseCertificate($cert); 657 | $chain[]='['.$info['issuer']['CN'].']'; 658 | } 659 | 660 | if (!$alternate) { 661 | if (isset($ret['headers']['link']['alternate'])){ 662 | $this->alternate_chains=$ret['headers']['link']['alternate']; 663 | }else{ 664 | $this->alternate_chains=array(); 665 | } 666 | } 667 | 668 | $this->log(($alternate?'Alternate':'Default').' certificate-chain retrieved: '.implode(' -> ',array_reverse($chain,true))); 669 | return $ret['body']; 670 | } 671 | 672 | private function tmp_ssl_cnf($domains=null,$extension=''){ 673 | if (false===($fn=tempnam(sys_get_temp_dir(), "CNF_"))){ 674 | throw new Exception('Failed to create temp file !'); 675 | } 676 | if (false===@file_put_contents($fn, 677 | 'HOME = .'."\n". 678 | 'RANDFILE=$ENV::HOME/.rnd'."\n". 679 | '[v3_ca]'."\n". 680 | '[req]'."\n". 681 | 'default_bits=2048'."\n". 682 | ($domains? 683 | 'distinguished_name=req_distinguished_name'."\n". 684 | '[req_distinguished_name]'."\n". 685 | '[v3_req]'."\n". 686 | '[SAN]'."\n". 687 | 'subjectAltName='. 688 | implode(',',array_map(function($domain){ 689 | if (filter_var($domain,FILTER_VALIDATE_IP)){ 690 | return 'IP:'.$domain; 691 | }else{ 692 | return 'DNS:'.$domain; 693 | } 694 | 695 | },$domains))."\n" 696 | : 697 | '' 698 | ).$extension 699 | )){ 700 | throw new Exception('Failed to write tmp file: '.$fn); 701 | } 702 | return $fn; 703 | } 704 | 705 | private function pem2der($pem) { 706 | return base64_decode(implode('',array_slice( 707 | array_map('trim',explode("\n",trim($pem))),1,-1 708 | ))); 709 | } 710 | 711 | private function make_contacts_array($contacts){ 712 | if (!is_array($contacts)) { 713 | $contacts=$contacts?array($contacts):array(); 714 | } 715 | return array_map(function($contact){ 716 | return 'mailto:'.$contact; 717 | },$contacts); 718 | } 719 | 720 | private function getTopIssuerCN($chain){ 721 | $tmp=$this->splitChain($chain); 722 | $ret=$this->parseCertificate(end($tmp)); 723 | return $ret['issuer']['CN']; 724 | } 725 | 726 | public function splitChain($chain){ 727 | $delim='-----END CERTIFICATE-----'; 728 | return array_map(function($item)use($delim){ 729 | return trim($item.$delim); 730 | },array_filter(explode($delim,$chain),function($item){ 731 | return strpos($item,'-----BEGIN CERTIFICATE-----')!==false; 732 | })); 733 | } 734 | } 735 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ACMECert v3.7.1 2 | 3 | PHP client library for [Let's Encrypt](https://letsencrypt.org/) and other [ACME v2 - RFC 8555](https://tools.ietf.org/html/rfc8555) compatible Certificate Authorities. 4 | 5 | ## Table of Contents 6 | - [Description](#description) 7 | - [Requirements](#requirements) 8 | - [Require ACMECert](#require-acmecert) 9 | - [Usage / Examples](#usage--examples) 10 | - [Logging](#logging) 11 | - [ACME_Exception](#acme_exception) 12 | - [Function Reference](#function-reference) 13 | 14 | ## Description 15 | 16 | ACMECert is designed to help you set up an automated SSL/TLS certificate/renewal process with just a few lines of PHP. 17 | 18 | It is self contained and contains a set of functions allowing you to: 19 | 20 | - generate [RSA](#acmecertgeneratersakey) / [EC (Elliptic Curve)](#acmecertgenerateeckey) keys 21 | - manage account: [register](#acmecertregister)/[External Account Binding (EAB)](#acmecertregistereab)/[update](#acmecertupdate)/[deactivate](#acmecertdeactivateaccount) and [account key roll-over](#acmecertkeychange) 22 | - [get](#acmecertgetcertificatechain)/[revoke](#acmecertrevoke) certificates (to renew a certificate just get a new one) 23 | - [parse certificates](#acmecertparsecertificate) / get the [remaining days](#acmecertgetremainingdays) or [percentage](#acmecertgetremainingpercent) a certificate is still valid 24 | - get/use [ACME Renewal Information](#acmecertgetari) (ARI) 25 | - get/use [ACME certificate profiles](#acmecertgetprofiles) 26 | - issue IP address certificates 27 | - and more.. 28 | > see [Function Reference](#function-reference) for a full list 29 | 30 | It abstracts away the complexity of the ACME protocol to get a certificate 31 | (create order, fetch authorizations, compute challenge tokens, polling for status, generate CSR, 32 | finalize order, request certificate) into a single function [getCertificateChain](#acmecertgetcertificatechain) (or [getCertificateChains](#acmecertgetcertificatechains) to also get all alternate chains), 33 | where you specify a set of domains you want to get a certificate for and which challenge type to use (all [challenge types](https://letsencrypt.org/docs/challenge-types/) are supported). 34 | This function takes as third argument a user-defined callback function which gets 35 | invoked every time a challenge needs to be fulfilled. It is up to you to set/remove the challenge tokens: 36 | 37 | ```php 38 | $handler=function($opts){ 39 | // Write code to setup the challenge token here. 40 | 41 | // Return a function that gets called when the challenge token should be removed again: 42 | return function($opts){ 43 | // Write code to remove previously setup challenge token. 44 | }; 45 | }; 46 | 47 | $ac->getCertificateChain(..., ..., $handler); 48 | ``` 49 | > see description of [getCertificateChain](#acmecertgetcertificatechain) for details about the callback function. 50 | > 51 | > also see the [Get Certificate](#get-certificate-using-http-01-challenge) examples below. 52 | 53 | Instead of returning `FALSE` on error, every function in ACMECert throws an [Exception](http://php.net/manual/en/class.exception.php) 54 | if it fails or an [ACME_Exception](#acme_exception) if the ACME-Server responded with an error message. 55 | 56 | ## Requirements 57 | - [x] PHP 5.6 or higher (for EC keys PHP 7.1 or higher) (for ARI PHP 7.1.2 or higher) 58 | - [x] [OpenSSL extension](https://www.php.net/manual/de/book.openssl.php) 59 | - [x] enabled [fopen wrappers](https://www.php.net/manual/en/filesystem.configuration.php#ini.allow-url-fopen) (allow_url_fopen=1) **or** [cURL extension](https://www.php.net/manual/en/book.curl.php) 60 | 61 | ## Require ACMECert 62 | 63 | manual download: https://github.com/skoerfgen/ACMECert/archive/master.zip 64 | 65 | usage: 66 | 67 | ```php 68 | require 'ACMECert.php'; 69 | 70 | use skoerfgen\ACMECert\ACMECert; 71 | ``` 72 | 73 | --- 74 | 75 | or download it using [git](https://git-scm.com/): 76 | 77 | ``` 78 | git clone https://github.com/skoerfgen/ACMECert 79 | ``` 80 | usage: 81 | 82 | ```php 83 | require 'ACMECert/ACMECert.php'; 84 | 85 | use skoerfgen\ACMECert\ACMECert; 86 | ``` 87 | 88 | --- 89 | 90 | or download it using [composer](https://getcomposer.org): 91 | ``` 92 | composer require skoerfgen/acmecert 93 | ``` 94 | 95 | usage: 96 | 97 | ```php 98 | require 'vendor/autoload.php'; 99 | 100 | use skoerfgen\ACMECert\ACMECert; 101 | ``` 102 | 103 | ## Usage / Examples 104 | 105 | * [Simple example to get started](https://github.com/skoerfgen/ACMECert/wiki/Simple-example-to-get-started) 106 | 107 | #### Choose Certificate Authority (CA) 108 | ##### [Let's Encrypt](https://letsencrypt.org/) 109 | > Live CA 110 | ```php 111 | $ac=new ACMECert('https://acme-v02.api.letsencrypt.org/directory'); 112 | ``` 113 | 114 | > Staging CA 115 | ```php 116 | $ac=new ACMECert('https://acme-staging-v02.api.letsencrypt.org/directory'); 117 | ``` 118 | 119 | ##### [Buypass](https://buypass.com/) 120 | > Live CA 121 | ```php 122 | $ac=new ACMECert('https://api.buypass.com/acme/directory'); 123 | ``` 124 | 125 | > Staging CA 126 | ```php 127 | $ac=new ACMECert('https://api.test4.buypass.no/acme/directory'); 128 | ``` 129 | 130 | ##### [Google Trust Services](https://pki.goog/) 131 | > Live CA 132 | ```php 133 | $ac=new ACMECert('https://dv.acme-v02.api.pki.goog/directory'); 134 | ``` 135 | 136 | > Staging CA 137 | ```php 138 | $ac=new ACMECert('https://dv.acme-v02.test-api.pki.goog/directory'); 139 | ``` 140 | 141 | ##### [SSL.com](https://www.ssl.com/) 142 | > Live CA 143 | ```php 144 | $ac=new ACMECert('https://acme.ssl.com/sslcom-dv-rsa'); 145 | ``` 146 | 147 | ##### [ZeroSSL](https://zerossl.com/) 148 | > Live CA 149 | ```php 150 | $ac=new ACMECert('https://acme.zerossl.com/v2/DV90'); 151 | ``` 152 | 153 | ##### or any other ([ACME v2 - RFC 8555](https://tools.ietf.org/html/rfc8555)) compatible CA 154 | ```php 155 | $ac=new ACMECert('INSERT_URL_TO_ACME_CA_DIRECTORY_HERE'); 156 | ``` 157 | 158 | #### Generate RSA Private Key 159 | ```php 160 | $key=$ac->generateRSAKey(2048); 161 | file_put_contents('account_key.pem',$key); 162 | ``` 163 | > Equivalent to: `openssl genrsa -out account_key.pem 2048` 164 | 165 | #### Generate EC Private Key 166 | ```php 167 | $key=$ac->generateECKey('P-384'); 168 | file_put_contents('account_key.pem',$key); 169 | ``` 170 | > Equivalent to: `openssl ecparam -name secp384r1 -genkey -noout -out account_key.pem` 171 | 172 | #### Register Account Key with CA 173 | ```php 174 | $ac->loadAccountKey('file://'.'account_key.pem'); 175 | $ret=$ac->register(true,'info@example.com'); 176 | print_r($ret); 177 | ``` 178 | 179 | #### Register Account Key with CA using External Account Binding 180 | ```php 181 | $ac->loadAccountKey('file://'.'account_key.pem'); 182 | $ret=$ac->registerEAB(true,'INSERT_EAB_KEY_ID_HERE','INSERT_EAB_HMAC_HERE','info@example.com'); 183 | print_r($ret); 184 | ``` 185 | 186 | #### Get Certificate using `http-01` challenge 187 | ```php 188 | $ac->loadAccountKey('file://'.'account_key.pem'); 189 | 190 | $domain_config=array( 191 | 'test1.example.com'=>array('challenge'=>'http-01','docroot'=>'/var/www/vhosts/test1.example.com'), 192 | 'test2.example.com'=>array('challenge'=>'http-01','docroot'=>'/var/www/vhosts/test2.example.com') 193 | ); 194 | 195 | $handler=function($opts){ 196 | $fn=$opts['config']['docroot'].$opts['key']; 197 | @mkdir(dirname($fn),0777,true); 198 | file_put_contents($fn,$opts['value']); 199 | return function($opts){ 200 | unlink($opts['config']['docroot'].$opts['key']); 201 | }; 202 | }; 203 | 204 | // Generate new certificate key 205 | $private_key=$ac->generateRSAKey(2048); 206 | 207 | $fullchain=$ac->getCertificateChain($private_key,$domain_config,$handler); 208 | file_put_contents('fullchain.pem',$fullchain); 209 | file_put_contents('private_key.pem',$private_key); 210 | ``` 211 | 212 | #### Get Certificate using all (`http-01`,`dns-01` and `tls-alpn-01`) challenge types together 213 | ```php 214 | $ac->loadAccountKey('file://'.'account_key.pem'); 215 | 216 | $domain_config=array( 217 | 'example.com'=>array('challenge'=>'http-01','docroot'=>'/var/www/vhosts/example.com'), 218 | '*.example.com'=>array('challenge'=>'dns-01'), 219 | 'test.example.org'=>array('challenge'=>'tls-alpn-01') 220 | ); 221 | 222 | $handler=function($opts) use ($ac){ 223 | switch($opts['config']['challenge']){ 224 | case 'http-01': // automatic example: challenge directory/file is created.. 225 | $fn=$opts['config']['docroot'].$opts['key']; 226 | @mkdir(dirname($fn),0777,true); 227 | file_put_contents($fn,$opts['value']); 228 | return function($opts) use ($fn){ // ..and removed after validation completed 229 | unlink($fn); 230 | }; 231 | break; 232 | case 'dns-01': // manual example: 233 | echo 'Create DNS-TXT-Record '.$opts['key'].' with value '.$opts['value']."\n"; 234 | readline('Ready?'); 235 | return function($opts){ 236 | echo 'Remove DNS-TXT-Record '.$opts['key'].' with value '.$opts['value']."\n"; 237 | }; 238 | break; 239 | case 'tls-alpn-01': 240 | $cert=$ac->generateALPNCertificate('file://'.'some_private_key.pem',$opts['domain'],$opts['value']); 241 | // Use $cert and some_private_key.pem(<- does not have to be a specific key, 242 | // just make sure you generated one) to serve the certificate for $opts['domain'] 243 | 244 | 245 | // This example uses an included ALPN Responder - a standalone https-server 246 | // written in a few lines of node.js - which is able to complete this challenge. 247 | 248 | // store the generated verification certificate to be used by the ALPN Responder. 249 | file_put_contents('alpn_cert.pem',$cert); 250 | 251 | // To keep this example simple, the included Example ALPN Responder listens on port 443, 252 | // so - for the sake of this example - you have to stop the webserver here, like: 253 | shell_exec('/etc/init.d/apache2 stop'); 254 | 255 | // Start ALPN Responder (requires node.js) 256 | $resource=proc_open( 257 | 'node alpn_responder.js some_private_key.pem alpn_cert.pem', 258 | array( 259 | 0=>array('pipe','r'), 260 | 1=>array('pipe','w') 261 | ), 262 | $pipes 263 | ); 264 | 265 | // wait until alpn responder is listening 266 | fgets($pipes[1]); 267 | 268 | return function($opts) use ($resource,$pipes){ 269 | // Stop ALPN Responder 270 | fclose($pipes[0]); 271 | fclose($pipes[1]); 272 | proc_terminate($resource); 273 | proc_close($resource); 274 | shell_exec('/etc/init.d/apache2 start'); 275 | }; 276 | break; 277 | } 278 | }; 279 | 280 | // Example for using a pre-generated CSR as input to getCertificateChain instead of a private key: 281 | // $csr=$ac->generateCSR('file://'.'cert_private_key.pem',array_keys($domain_config)); 282 | // $fullchain=$ac->getCertificateChain($csr,$domain_config,$handler); 283 | 284 | $fullchain=$ac->getCertificateChain('file://'.'cert_private_key.pem',$domain_config,$handler); 285 | file_put_contents('fullchain.pem',$fullchain); 286 | ``` 287 | 288 | #### Get alternate chains 289 | ```php 290 | $chains=$ac->getCertificateChains('file://'.'cert_private_key.pem',$domain_config,$handler); 291 | if (isset($chains['ISRG Root X1'])){ // use alternate chain 'ISRG Root X1' 292 | $fullchain=$chains['ISRG Root X1']; 293 | }else{ // use default chain if 'ISRG Root X1' is not present 294 | $fullchain=reset($chains); 295 | } 296 | file_put_contents('fullchain.pem',$fullchain); 297 | ``` 298 | 299 | #### Revoke Certificate 300 | ```php 301 | $ac->loadAccountKey('file://'.'account_key.pem'); 302 | $ac->revoke('file://'.'fullchain.pem'); 303 | ``` 304 | 305 | #### Get Account Information 306 | ```php 307 | $ac->loadAccountKey('file://'.'account_key.pem'); 308 | $ret=$ac->getAccount(); 309 | print_r($ret); 310 | ``` 311 | 312 | #### Account Key Roll-over 313 | ```php 314 | $ac->loadAccountKey('file://'.'account_key.pem'); 315 | $ret=$ac->keyChange('file://'.'new_account_key.pem'); 316 | print_r($ret); 317 | ``` 318 | 319 | #### Deactivate Account 320 | ```php 321 | $ac->loadAccountKey('file://'.'account_key.pem'); 322 | $ret=$ac->deactivateAccount(); 323 | print_r($ret); 324 | ``` 325 | 326 | #### Get/Use ACME Renewal Information 327 | ```php 328 | $ret=$ac->getARI('file://'.'fullchain.pem'); 329 | if ($ret['suggestedWindow']['start']-time()>0) { 330 | die('Certificate still good, exiting..'); 331 | } 332 | 333 | $settings=array( 334 | 'replaces'=>$ret['ari_cert_id'] 335 | ); 336 | $ac->getCertificateChain(..., ..., ..., $settings); 337 | ``` 338 | 339 | #### Get Remaining Percentage 340 | ```php 341 | $percent=$ac->getRemainingPercent('file://'.'fullchain.pem'); // certificate or certificate-chain 342 | if ($percent>33.333) { // certificate has still more than 1/3 (33.333%) of its lifetime left 343 | die('Certificate still good, exiting..'); 344 | } 345 | // get new certificate here.. 346 | ``` 347 | > This allows you to run your renewal script without the need to time it exactly, just run it often enough. (cronjob) 348 | 349 | 350 | #### Get Remaining Days 351 | ```php 352 | $days=$ac->getRemainingDays('file://'.'fullchain.pem'); // certificate or certificate-chain 353 | if ($days>30) { // renew 30 days before expiry 354 | die('Certificate still good, exiting..'); 355 | } 356 | // get new certificate here.. 357 | ``` 358 | 359 | #### ACME certificate profiles 360 | ```php 361 | $ret=$ac->getProfiles(); 362 | print_r($ret); // print available profiles 363 | 364 | // use profile with name "classic" 365 | $settings=array( 366 | 'profile'=>'classic' 367 | ); 368 | $ac->getCertificateChain(..., ..., ..., $settings); 369 | ``` 370 | 371 | ## Logging 372 | 373 | By default ACMECert logs its actions using `error_log` which logs messages to stderr in PHP CLI so it is easy to log to a file instead: 374 | ```php 375 | error_reporting(E_ALL); 376 | ini_set('log_errors',1); 377 | ini_set('error_log',dirname(__FILE__).'/ACMECert.log'); 378 | ``` 379 | 380 | > To disable the default logging, you can use [`setLogger`](#acmecertsetlog), Exceptions are nevertheless thrown: 381 | ```php 382 | $ac->setLogger(false); 383 | ``` 384 | > Or you can you set it to a custom callback function: 385 | ```php 386 | $ac->setLogger(function($txt){ 387 | echo 'Log Message: '.$txt."\n"; 388 | }); 389 | ``` 390 | ## ACME_Exception 391 | 392 | If the ACME-Server responded with an error message an `\skoerfgen\ACMECert\ACME_Exception` is thrown. (ACME_Exception extends Exception) 393 | 394 | `ACME_Exception` has two additional functions: 395 | 396 | * `getType()` to get the ACME error code: 397 | 398 | ```php 399 | use skoerfgen\ACMECert\ACME_Exception; 400 | 401 | try { 402 | echo $ac->getAccountID().PHP_EOL; 403 | }catch(ACME_Exception $e){ 404 | if ($e->getType()=='urn:ietf:params:acme:error:accountDoesNotExist'){ 405 | echo 'Account does not exist'.PHP_EOL; 406 | }else{ 407 | throw $e; // another error occurred 408 | } 409 | } 410 | ``` 411 | 412 | * `getSubproblems()` to get an array of `ACME_Exception`s if there is more than one error returned from the ACME-Server: 413 | 414 | ```php 415 | try { 416 | $cert=$ac->getCertificateChain('file://'.'cert_private_key.pem',$domain_config,$handler); 417 | } catch (\skoerfgen\ACMECert\ACME_Exception $e){ 418 | $ac->log($e->getMessage()); // log original error 419 | foreach($e->getSubproblems() as $subproblem){ 420 | $ac->log($subproblem->getMessage()); // log sub errors 421 | } 422 | } 423 | ``` 424 | 425 | ## Function Reference 426 | 427 | ### ACMECert::__construct 428 | 429 | Creates a new ACMECert instance. 430 | ```php 431 | public ACMECert::__construct ( string $ca_url = 'https://acme-v02.api.letsencrypt.org/directory' ) 432 | ``` 433 | ###### Parameters 434 | > **`ca_url`** 435 | > 436 | > A string containing the URL to an ACME CA directory endpoint. 437 | 438 | ###### Return Values 439 | > Returns a new ACMECert instance. 440 | 441 | --- 442 | 443 | ### ACMECert::generateRSAKey 444 | 445 | Generate RSA private key (used as account key or private key for a certificate). 446 | ```php 447 | public string ACMECert::generateRSAKey ( int $bits = 2048 ) 448 | ``` 449 | ###### Parameters 450 | > **`bits`** 451 | > 452 | > RSA key size in bits. 453 | 454 | ###### Return Values 455 | > Returns the generated RSA private key as PEM encoded string. 456 | ###### Errors/Exceptions 457 | > Throws an `Exception` if the RSA key could not be generated. 458 | 459 | --- 460 | ### ACMECert::generateECKey 461 | 462 | Generate Elliptic Curve (EC) private key (used as account key or private key for a certificate). 463 | ```php 464 | public string ACMECert::generateECKey ( string $curve_name = 'P-384' ) 465 | ``` 466 | ###### Parameters 467 | > **`curve_name`** 468 | > 469 | > Supported Curves by Let’s Encrypt: 470 | > * `P-256` (prime256v1) 471 | > * `P-384` (secp384r1) 472 | > * ~~`P-521` (secp521r1)~~ 473 | 474 | 475 | ###### Return Values 476 | > Returns the generated EC private key as PEM encoded string. 477 | ###### Errors/Exceptions 478 | > Throws an `Exception` if the EC key could not be generated. 479 | 480 | --- 481 | 482 | ### ACMECert::loadAccountKey 483 | 484 | Load account key. 485 | ```php 486 | public void ACMECert::loadAccountKey ( mixed $account_key_pem ) 487 | ``` 488 | ###### Parameters 489 | > **`account_key_pem`** 490 | > 491 | > can be one of the following: 492 | > * a string containing a PEM formatted private key. 493 | > * a string beginning with `file://` containing the filename to read a PEM formatted private key from. 494 | ###### Return Values 495 | > No value is returned. 496 | ###### Errors/Exceptions 497 | > Throws an `Exception` if the account key could not be loaded. 498 | 499 | --- 500 | 501 | ### ACMECert::register 502 | 503 | Associate the loaded account key with the CA account and optionally specify contacts. 504 | ```php 505 | public array ACMECert::register ( bool $termsOfServiceAgreed = FALSE [, mixed $contacts = array() ] ) 506 | ``` 507 | ###### Parameters 508 | > **`termsOfServiceAgreed`** 509 | > 510 | > By passing `TRUE`, you agree to the Terms Of Service of the selected CA. (Must be set to `TRUE` in order to successfully register an account.) 511 | > 512 | > Hint: Use [getTermsURL()](#acmecertgettermsurl) to get the link to the current Terms Of Service. 513 | 514 | 515 | > **`contacts`** 516 | > 517 | > can be one of the following: 518 | > 1. A string containing an e-mail address 519 | > 2. Array of e-mail addresses 520 | ###### Return Values 521 | > Returns an array containing the account information. 522 | ###### Errors/Exceptions 523 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other registration error occurred. 524 | 525 | --- 526 | 527 | ### ACMECert::registerEAB 528 | 529 | Associate the loaded account key with the CA account using External Account Binding (EAB) credentials and optionally specify contacts. 530 | ```php 531 | public array ACMECert::registerEAB ( bool $termsOfServiceAgreed, string $eab_kid, string $eab_hmac [, mixed $contacts = array() ] ) 532 | ``` 533 | ###### Parameters 534 | > **`termsOfServiceAgreed`** 535 | > 536 | > By passing `TRUE`, you agree to the Terms Of Service of the selected CA. (Must be set to `TRUE` in order to successfully register an account.) 537 | > 538 | > Hint: Use [getTermsURL()](#acmecertgettermsurl) to get the link to the current Terms Of Service. 539 | 540 | > **`eab_kid`** 541 | > 542 | > a string specifying the `EAB Key Identifier` 543 | 544 | > **`eab_hmac`** 545 | > 546 | > a string specifying the `EAB HMAC Key` 547 | 548 | > **`contacts`** 549 | > 550 | > can be one of the following: 551 | > 1. A string containing an e-mail address 552 | > 2. Array of e-mail addresses 553 | ###### Return Values 554 | > Returns an array containing the account information. 555 | ###### Errors/Exceptions 556 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other registration error occurred. 557 | 558 | --- 559 | 560 | ### ACMECert::update 561 | 562 | Update account contacts. 563 | ```php 564 | public array ACMECert::update ( mixed $contacts = array() ) 565 | ``` 566 | ###### Parameters 567 | > **`contacts`** 568 | > 569 | > can be one of the following: 570 | > * A string containing an e-mail address 571 | > * Array of e-mail addresses 572 | ###### Return Values 573 | > Returns an array containing the account information. 574 | ###### Errors/Exceptions 575 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occurred updating the account. 576 | 577 | --- 578 | 579 | ### ACMECert::getAccount 580 | 581 | Get Account Information. 582 | ```php 583 | public array ACMECert::getAccount() 584 | ``` 585 | ###### Return Values 586 | > Returns an array containing the account information. 587 | ###### Errors/Exceptions 588 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occurred getting the account information. 589 | 590 | --- 591 | 592 | ### ACMECert::getAccountID 593 | 594 | Get Account ID. 595 | ```php 596 | public string ACMECert::getAccountID() 597 | ``` 598 | ###### Return Values 599 | > Returns the Account ID 600 | ###### Errors/Exceptions 601 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occurred getting the account id. 602 | 603 | --- 604 | 605 | ### ACMECert::keyChange 606 | 607 | Account Key Roll-over (exchange the current account key with another one). 608 | 609 | > If the Account Key Roll-over succeeded, the new account key is automatically loaded via [`loadAccountKey`](#acmecertloadaccountkey) 610 | ```php 611 | public array ACMECert::keyChange ( mixed $new_account_key_pem ) 612 | ``` 613 | ###### Parameters 614 | > **`new_account_key_pem`** 615 | > 616 | > can be one of the following: 617 | > * a string containing a PEM formatted private key. 618 | > * a string beginning with `file://` containing the filename to read a PEM formatted private key from. 619 | ###### Return Values 620 | > Returns an array containing the account information. 621 | ###### Errors/Exceptions 622 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occurred during key change. 623 | 624 | --- 625 | 626 | ### ACMECert::deactivateAccount 627 | 628 | Deactivate account. 629 | ```php 630 | public array ACMECert::deactivateAccount() 631 | ``` 632 | ###### Return Values 633 | > Returns an array containing the account information. 634 | ###### Errors/Exceptions 635 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occurred during account deactivation. 636 | 637 | --- 638 | 639 | ### ACMECert::getCertificateChain 640 | 641 | Get certificate-chain (certificate + the intermediate certificate(s)). 642 | 643 | *This is what Apache >= 2.4.8 needs for [`SSLCertificateFile`](https://httpd.apache.org/docs/current/mod/mod_ssl.html#sslcertificatefile), and what Nginx needs for [`ssl_certificate`](http://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_certificate).* 644 | ```php 645 | public string ACMECert::getCertificateChain ( mixed $pem, array $domain_config, callable $callback, array $settings = array() ) 646 | ``` 647 | ###### Parameters 648 | > **`pem`** 649 | > 650 | > A Private Key used for the certificate (the needed CSR is generated automatically using the given key in this case) or an already existing CSR in one of the following formats: 651 | > 652 | > * a string containing a PEM formatted private key. 653 | > * a string beginning with `file://` containing the filename to read a PEM encoded private key from. 654 | > or 655 | > * a string beginning with `file://` containing the filename to read a PEM encoded CSR from. 656 | > * a string containing the content of a CSR, PEM encoded, may start with `-----BEGIN CERTIFICATE REQUEST-----` 657 | 658 | > **`domain_config`** 659 | > 660 | > An Array defining the domains and the corresponding challenge types to get a certificate for. 661 | > 662 | > The first domain name in the array is used as `Common Name` for the certificate if it does not exceed 64 characters, otherwise the `Common Name` field will be empty. 663 | > 664 | > Here is an example structure: 665 | > ```php 666 | > $domain_config=array( 667 | > '*.example.com'=>array('challenge'=>'dns-01'), 668 | > 'test.example.org'=>array('challenge'=>'tls-alpn-01') 669 | > 'test.example.net'=>array('challenge'=>'http-01','docroot'=>'/var/www/vhosts/test1.example.com'), 670 | > ); 671 | > ``` 672 | > > Hint: Wildcard certificates (`*.example.com`) are only supported with the `dns-01` challenge type. 673 | > 674 | > `challenge` is mandatory and has to be one of `http-01`, `dns-01` or `tls-alpn-01`. 675 | > All other keys are optional and up to you to be used and are later available in the callback function as `$opts['config']` 676 | > (see the [http-01 example](#get-certificate-using-http-01-challenge) where `docroot` is used this way) 677 | 678 | > **`callback`** 679 | > 680 | > Callback function which gets invoked every time a challenge needs to be fulfilled. 681 | > ```php 682 | > callable callback ( array $opts ) 683 | > ``` 684 | > 685 | > Inside a callback function you can return another callback function, which gets invoked after the verification completed and the challenge tokens can be removed again. 686 | > 687 | > > Hint: To get access to variables of the parent scope inside the callback function use the [`use`](http://php.net/manual/en/functions.anonymous.php) language construct: 688 | > > ```php 689 | > > $handler=function($opts) use ($variable_from_parent_scope){}; 690 | > > ^^^ 691 | > > ``` 692 | > 693 | > The `$opts` array passed to the callback function contains the following keys: 694 | > 695 | >> **`$opts['domain']`** 696 | >> 697 | >> Domain name to be validated. 698 | >> 699 | >> **`$opts['config']`** 700 | >> 701 | >> Corresponding element of the `domain_config` array. 702 | >> 703 | >> 704 | >> **`$opts['key']`** and **`$opts['value']`** 705 | >> 706 | >> Contain the following, depending on the chosen challenge type: 707 | >> 708 | >> Challenge Type | `$opts['key']` | `$opts['value']` 709 | >> --- | --- | --- 710 | >> http-01 | path + filename | file contents 711 | >> dns-01 | TXT Resource Record Name | TXT Resource Record Value 712 | >> tls-alpn-01 | unused | token used in the acmeIdentifier extension of the verification certificate; use [generateALPNCertificate](#acmecertgeneratealpncertificate) to generate the verification certificate from that token. (see the [tls-alpn-01 example](#get-certificate-using-all-http-01dns-01-and-tls-alpn-01-challenge-types-together)) 713 | 714 | 715 | > **`settings`** (optional) 716 | > 717 | > This array can have the following keys: 718 | >> **`authz_reuse`** (boolean / default: `TRUE`) 719 | >> 720 | >> If `FALSE` the callback function is always called for each domain and does not get skipped due to possibly already valid authorizations (authz) that are reused. This is achieved by deactivating already valid authorizations before getting new ones. 721 | >> 722 | >> > Hint: Under normal circumstances this is only needed when testing the callback function, not in production! 723 | > 724 | >> **`notBefore`** / **`notAfter`** (mixed) 725 | >> 726 | >> can be one of the following: 727 | >> * a string containing a RFC 3339 formatted date 728 | >> * a timestamp (integer) 729 | >> 730 | >> Example: Certificate valid for 3 days: 731 | >> ```php 732 | >> array( 'notAfter' => time() + (60*60*24) * 3 ) 733 | >> ``` 734 | >> or 735 | >> ```php 736 | >> array( 'notAfter' => '1970-01-01T01:22:17+01:00' ) 737 | >> ``` 738 | > 739 | >> **`replaces`** (string) 740 | >> 741 | >> The ARI CertID uniquely identifying a previously-issued certificate which this order is intended to replace. 742 | >> 743 | >> Use: [getARI](#acmecertgetari) to get the ARI CertID for a certificate. 744 | >> 745 | >> Example: [Get/Use ACME Renewal Information](#getuse-acme-renewal-information) 746 | > 747 | >> **`profile`** (string) 748 | >> 749 | >> The name of the profile to use. 750 | >> 751 | >> Use: [getProfiles](#acmecertgetprofiles) to get a list of available profiles. 752 | >> 753 | >> Example: [ACME certificate profiles](#acme-certificate-profiles) 754 | > 755 | >> **`group`** (boolean / default: `TRUE`) 756 | >> 757 | >> When issuing certificates using the `dns-01` challenge for multiple domains that share the same `_acme-challenge` subdomain, such as: 758 | >> - example.com 759 | >> - *.example.com (wildcard) 760 | >> 761 | >> two distinct TXT records must be created under the same DNS name `_acme-challenge.example.com` 762 | >> 763 | >> By default, ACMECert groups these challenges together. This means all required TXT records for `_acme-challenge.example.com` are set simultaneously, and validation is triggered only after all records are in place. This approach prevents validation failures due to DNS caching delays. 764 | >> 765 | >> If set to `FALSE` challenges are handled independently. Each TXT record gets set and validated one at a time. 766 | 767 | 768 | 769 | ###### Return Values 770 | > Returns a PEM encoded certificate chain. 771 | ###### Errors/Exceptions 772 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occurred obtaining the certificate. 773 | 774 | --- 775 | 776 | ### ACMECert::getCertificateChains 777 | 778 | Get all (default and alternate) certificate-chains. 779 | This function takes the same arguments as the [getCertificateChain](#acmecertgetcertificatechain) function above, but it returns an array of certificate chains instead of a single chain. 780 | 781 | ```php 782 | public string ACMECert::getCertificateChains ( mixed $pem, array $domain_config, callable $callback, array $settings = array() ) 783 | ``` 784 | 785 | ###### Return Values 786 | > Returns an array of PEM encoded certificate chains. 787 | > 788 | > The keys of the returned array correspond to the issuer `Common Name` (CN) of the topmost (closest to the root certificate) intermediate certificate. 789 | > 790 | > The first element of the returned array is the default chain. 791 | ###### Errors/Exceptions 792 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occurred obtaining the certificate chains. 793 | 794 | --- 795 | 796 | ### ACMECert::revoke 797 | 798 | Revoke certificate. 799 | ```php 800 | public void ACMECert::revoke ( mixed $pem ) 801 | ``` 802 | ###### Parameters 803 | > **`pem`** 804 | > 805 | > can be one of the following: 806 | > * a string beginning with `file://` containing the filename to read a PEM encoded certificate or certificate-chain from. 807 | > * a string containing the content of a certificate or certificate-chain, PEM encoded, may start with `-----BEGIN CERTIFICATE-----` 808 | ###### Return Values 809 | > No value is returned. 810 | > 811 | > If the function completes without Exception, the certificate was successfully revoked. 812 | ###### Errors/Exceptions 813 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occurred revoking the certificate. 814 | 815 | --- 816 | 817 | ### ACMECert::generateCSR 818 | 819 | Generate CSR for a set of domains. 820 | ```php 821 | public string ACMECert::generateCSR ( mixed $private_key, array $domains ) 822 | ``` 823 | ###### Parameters 824 | > **`private_key`** 825 | > 826 | > can be one of the following: 827 | > * a string containing a PEM formatted private key. 828 | > * a string beginning with `file://` containing the filename to read a PEM formatted private key from. 829 | 830 | > **`domains`** 831 | > 832 | > Array of domains 833 | ###### Return Values 834 | > Returns the generated CSR as string. 835 | ###### Errors/Exceptions 836 | > Throws an `Exception` if the CSR could not be generated. 837 | 838 | --- 839 | 840 | ### ACMECert::generateALPNCertificate 841 | 842 | Generate a self signed verification certificate containing the acmeIdentifier extension used in **`tls-alpn-01`** challenge. 843 | ```php 844 | public string ACMECert::generateALPNCertificate ( mixed $private_key, string $domain, string $token ) 845 | ``` 846 | ###### Parameters 847 | > **`private_key`** 848 | > 849 | > private key used for the certificate. 850 | > 851 | > can be one of the following: 852 | > * a string containing a PEM formatted private key. 853 | > * a string beginning with `file://` containing the filename to read a PEM formatted private key from. 854 | 855 | > **`domain`** 856 | > 857 | > domain name to be validated. 858 | 859 | > **`token`** 860 | > 861 | > verification token. 862 | ###### Return Values 863 | > Returns a PEM encoded verification certificate. 864 | ###### Errors/Exceptions 865 | > Throws an `Exception` if the certificate could not be generated. 866 | 867 | --- 868 | 869 | ### ACMECert::parseCertificate 870 | 871 | Get information about a certificate. 872 | ```php 873 | public array ACMECert::parseCertificate ( mixed $pem ) 874 | ``` 875 | ###### Parameters 876 | > **`pem`** 877 | > 878 | > can be one of the following: 879 | > * a string beginning with `file://` containing the filename to read a PEM encoded certificate or certificate-chain from. 880 | > * a string containing the content of a certificate or certificate-chain, PEM encoded, may start with `-----BEGIN CERTIFICATE-----` 881 | ###### Return Values 882 | > Returns an array containing information about the certificate. 883 | ###### Errors/Exceptions 884 | > Throws an `Exception` if the certificate could not be parsed. 885 | 886 | --- 887 | 888 | ### ACMECert::getRemainingPercent 889 | 890 | Get the percentage the certificate is still valid. 891 | 892 | ```php 893 | public float ACMECert::getRemainingPercent( mixed $pem ) 894 | ``` 895 | ###### Parameters 896 | > **`pem`** 897 | > 898 | > can be one of the following: 899 | > * a string beginning with `file://` containing the filename to read a PEM encoded certificate or certificate-chain from. 900 | > * a string containing the content of a certificate or certificate-chain, PEM encoded, may start with `-----BEGIN CERTIFICATE-----` 901 | ###### Return Values 902 | > A float value containing the percentage the certificate is still valid. 903 | ###### Errors/Exceptions 904 | > Throws an `Exception` if the certificate could not be parsed. 905 | 906 | --- 907 | 908 | ### ACMECert::getRemainingDays 909 | 910 | Get the number of days the certificate is still valid. 911 | ```php 912 | public float ACMECert::getRemainingDays ( mixed $pem ) 913 | ``` 914 | ###### Parameters 915 | > **`pem`** 916 | > 917 | > can be one of the following: 918 | > * a string beginning with `file://` containing the filename to read a PEM encoded certificate or certificate-chain from. 919 | > * a string containing the content of a certificate or certificate-chain, PEM encoded, may start with `-----BEGIN CERTIFICATE-----` 920 | ###### Return Values 921 | > Returns how many days the certificate is still valid. 922 | ###### Errors/Exceptions 923 | > Throws an `Exception` if the certificate could not be parsed. 924 | 925 | --- 926 | 927 | ### ACMECert::splitChain 928 | 929 | Split a string containing a PEM encoded certificate chain into an array of individual certificates. 930 | ```php 931 | public array ACMECert::splitChain ( string $pem ) 932 | ``` 933 | ###### Parameters 934 | > **`pem`** 935 | > * a certificate-chain as string, PEM encoded. 936 | ###### Return Values 937 | > Returns an array of PEM encoded individual certificates. 938 | ###### Errors/Exceptions 939 | > None 940 | 941 | --- 942 | 943 | ### ACMECert::getCAAIdentities 944 | 945 | Get a list of all CAA Identities for the selected CA. (Useful for setting up CAA DNS Records) 946 | ```php 947 | public array ACMECert::getCAAIdentities() 948 | ``` 949 | ###### Return Values 950 | > Returns an array containing all CAA Identities for the selected CA. 951 | ###### Errors/Exceptions 952 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occurred getting the CAA Identities. 953 | 954 | --- 955 | 956 | ### ACMECert::getSAN 957 | 958 | Get all Subject Alternative Names of given certificate. 959 | ```php 960 | public array ACMECert::getSAN( mixed $pem ) 961 | ``` 962 | 963 | ###### Parameters 964 | > **`pem`** 965 | > 966 | > can be one of the following: 967 | > * a string beginning with `file://` containing the filename to read a PEM encoded certificate or certificate-chain from. 968 | > * a string containing the content of a certificate or certificate-chain, PEM encoded, may start with `-----BEGIN CERTIFICATE-----` 969 | 970 | 971 | ###### Return Values 972 | > Returns an array containing all Subject Alternative Names of given certificate. 973 | ###### Errors/Exceptions 974 | > Throws an `Exception` if an error occurred getting the Subject Alternative Names. 975 | 976 | --- 977 | 978 | ### ACMECert::getTermsURL 979 | 980 | Get URL to Terms Of Service for the selected CA. 981 | ```php 982 | public array ACMECert::getTermsURL() 983 | ``` 984 | ###### Return Values 985 | > Returns a string containing a URL to the Terms Of Service for the selected CA. 986 | ###### Errors/Exceptions 987 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occurred getting the Terms Of Service. 988 | 989 | --- 990 | 991 | ### ACMECert::setLogger 992 | 993 | Turn on/off logging to stderr using `error_log` or provide a custom callback function. 994 | ```php 995 | public void ACMECert::setLogger( bool|callable $value = TRUE ) 996 | ``` 997 | ###### Parameters 998 | > **`value`** 999 | > 1000 | > - If `TRUE`, logging to stderr using `error_log` is enabled. (default) 1001 | > - If `FALSE`, logging is disabled. 1002 | > - If a callback function is provided, the function gets called with the log message as first argument: 1003 | > ```php 1004 | > void callback( string $txt ) 1005 | > ``` 1006 | > see [Logging](#logging) 1007 | ###### Return Values 1008 | > No value is returned. 1009 | ###### Errors/Exceptions 1010 | > Throws an `Exception` if the value provided is not boolean or a callable function. 1011 | 1012 | --- 1013 | ### ACMECert::getARI 1014 | 1015 | Get ACME Renewal Information (ARI) for a given certificate. 1016 | 1017 | ```php 1018 | public array ACMECert::getARI( mixed $pem ) 1019 | ``` 1020 | ###### Parameters 1021 | > **`pem`** 1022 | > 1023 | > can be one of the following: 1024 | > * a string beginning with `file://` containing the filename to read a PEM encoded certificate or certificate-chain from. 1025 | > * a string containing the content of a certificate or certificate-chain, PEM encoded, may start with `-----BEGIN CERTIFICATE-----` 1026 | ###### Return Values 1027 | > Returns an Array with the following keys: 1028 | > 1029 | >> `suggestedWindow` (array) 1030 | >> 1031 | >> An Array with two keys, `start` and `end`, whose values are unix timestamps, which bound the window of time in which the CA recommends renewing the certificate. 1032 | >> 1033 | >> `explanationURL` (string, optional) 1034 | >> 1035 | >> If present, contains a URL pointing to a page which may explain why the suggested renewal window is what it is. For example, it may be a page explaining the CA's dynamic load-balancing strategy, or a page documenting which certificates are affected by a mass revocation event. 1036 | >> 1037 | >> `ari_cert_id` (string) 1038 | >> 1039 | >> The ARI CertID of the given certificate. 1040 | >> 1041 | >> See the documentation of [getCertificateChain](#acmecertgetcertificatechain) where the ARI CertID can be used to replace an existing certificate using the `replaces` option. 1042 | >> 1043 | >> Example: [Get/Use ACME Renewal Information](#getuse-acme-renewal-information) 1044 | >> 1045 | >> `retry_after` (int, optional) 1046 | >> 1047 | >> If present, this value indicates the number of seconds a client should wait before retrying a request to [getARI](#acmecertgetari) for a given certificate, as the server may provide a different suggestedWindow. 1048 | >> 1049 | >> Clients SHOULD set reasonable limits on their checking interval. For example, values under one minute could be treated as if they were one minute, and values over one day could be treated as if they were one day. 1050 | ###### Errors/Exceptions 1051 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occurred getting the ACME Renewal Information. 1052 | 1053 | --- 1054 | ### ACMECert::getProfiles 1055 | 1056 | Get a list of supported profiles. (ACME certificate profiles) 1057 | 1058 | ```php 1059 | public array ACMECert::getProfiles() 1060 | ``` 1061 | 1062 | > See the documentation of [getCertificateChain](#acmecertgetcertificatechain) where a profile can be selected using the `profile` option. 1063 | > 1064 | > Example: [ACME certificate profiles](#acme-certificate-profiles) 1065 | ###### Return Values 1066 | > Returns an Array with the profile name as key and the description as value. 1067 | > 1068 | > Example: 1069 | > ```php 1070 | > Array 1071 | > ( 1072 | > [classic] => The same profile you're accustomed to 1073 | > [tlsserver] => https://letsencrypt.org/2025/01/09/acme-profiles/ 1074 | > ) 1075 | > ``` 1076 | ###### Errors/Exceptions 1077 | > Throws an `ACME_Exception` if the server responded with an error message or an `Exception` if an other error occurred getting the profiles. 1078 | 1079 | --- 1080 | 1081 | > MIT License 1082 | > 1083 | > Copyright (c) 2018 Stefan Körfgen 1084 | > 1085 | > Permission is hereby granted, free of charge, to any person obtaining a copy 1086 | > of this software and associated documentation files (the "Software"), to deal 1087 | > in the Software without restriction, including without limitation the rights 1088 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 1089 | > copies of the Software, and to permit persons to whom the Software is 1090 | > furnished to do so, subject to the following conditions: 1091 | > 1092 | > The above copyright notice and this permission notice shall be included in all 1093 | > copies or substantial portions of the Software. 1094 | > 1095 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 1096 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 1097 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 1098 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 1099 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 1100 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 1101 | > SOFTWARE. 1102 | --------------------------------------------------------------------------------