1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: use Module\Skeleton\Contracts\Hookable;
16: use Opcenter\Crypto\Ssl;
17: use Opcenter\SiteConfiguration;
18:
19: /**
20: * Provides SSL certificate management for Apache
21: *
22: * @package core
23: */
24: class Ssl_Module extends Module_Skeleton implements Hookable
25: {
26: const DEPENDENCY_MAP = [
27: 'apache',
28: 'siteinfo'
29: ];
30:
31: const CRT_PATH = '/etc/httpd/conf/ssl.crt';
32: const KEY_PATH = '/etc/httpd/conf/ssl.key';
33: const CSR_PATH = '/etc/httpd/conf/ssl.csr';
34: const DEFAULT_CERTIFICATE_NAME = 'server';
35:
36: const X509_DAYS = 1095; /* 3 years for self-signed */
37:
38: const USER_RHOOK = 'letsencrypt';
39: const SYS_RHOOK = 'ssl';
40:
41: public function __construct()
42: {
43: parent::__construct();
44: $this->exportedFunctions = array(
45: 'cert_exists' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
46: 'generate_csr' => PRIVILEGE_ALL,
47: 'generate_privatekey' => PRIVILEGE_ALL,
48: 'get_alternative_names' => PRIVILEGE_ALL,
49: 'has_certificate' => PRIVILEGE_SITE,
50: 'get_certificate' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
51: 'get_certificates' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
52: 'get_csr' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
53: 'get_private_key' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
54: 'get_public_key' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
55: 'is_self_signed' => PRIVILEGE_ALL,
56: 'key_exists' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
57: 'parse_certificate' => PRIVILEGE_ALL,
58: 'permitted' => PRIVILEGE_ALL,
59: 'privkey_info' => PRIVILEGE_ALL,
60: 'request_info' => PRIVILEGE_ALL,
61: 'resolve_chain' => PRIVILEGE_ALL,
62: 'sign_certificate' => PRIVILEGE_ALL,
63: 'valid' => PRIVILEGE_ALL,
64: 'verify_certificate_chain' => PRIVILEGE_ALL,
65: 'verify_key' => PRIVILEGE_ALL,
66: 'verify_x509_key' => PRIVILEGE_ALL,
67: 'server_certificate' => PRIVILEGE_ALL,
68: '*' => PRIVILEGE_SITE,
69: );
70: }
71:
72: /**
73: * Check if certificate is installed for account
74: *
75: * @return bool
76: */
77: public function cert_exists()
78: {
79: if (!IS_CLI) {
80: return $this->query('ssl_cert_exists');
81: }
82: $conf = $this->get_certificates();
83:
84: return count($conf) > 0;
85: }
86:
87: /**
88: * Get certificate names installed on account
89: *
90: * @return array
91: */
92: public function get_certificates()
93: {
94: if (!IS_CLI) {
95: return $this->query('ssl_get_certificates');
96: }
97:
98: // @TODO apache parser, maybe Augeas?
99: $that = $this;
100: $parser = static function ($config) use ($that) {
101: $conf = array();
102: $token = strtok($config, "\n \t");
103: while ($token !== false) {
104: switch (strtoupper($token)) {
105: case 'LISTEN':
106: $key = 'host';
107: break;
108: case 'SSLCERTIFICATEFILE':
109: $key = 'crt';
110: break;
111: case 'SSLCERTIFICATEKEYFILE':
112: $key = 'key';
113: break;
114: case 'SSLCERTIFICATECHAINFILE':
115: $key = 'chain';
116: break;
117: default:
118: $key = null;
119: break;
120: }
121: if (!is_null($key)) {
122: $token = trim(strtok("\t \n"));
123:
124: $constant = $key === 'chain' ? 'crt' : $key;
125: if ($constant == 'key' || $constant == 'crt') {
126: // if no matching file, invalidate certificate
127: if (!file_exists($token)) {
128: return array();
129: }
130: }
131: $token = $that->file_canonicalize_site($token);
132: // let's assume everything is organized nicely in /etc/httpd/conf/ssl.x
133: $conf[$key] = basename($token);
134: }
135: $token = strtok(" \t\n");
136: }
137: if (isset($conf['chain']) && count($conf) === 1) {
138: // separate config parser
139: return $conf;
140: } else {
141: if (!isset($conf['crt']) || !isset($conf['key'])) {
142: return array();
143: }
144: }
145:
146: return $conf;
147: };
148: if ($this->permission_level & PRIVILEGE_ADMIN) {
149: return [
150: 'host' => $this->common_get_ip_address()[0],
151: 'crt' => Ssl::systemCertificatePath(),
152: 'key' => Ssl::systemCertificatePath(),
153: ];
154: }
155:
156: $masterconfig = glob('/etc/httpd/conf/virtual/' . $this->site . '{,.*}', GLOB_BRACE);
157: $sitecerts = array();
158: $accountaddr = (array)$this->common_get_ip_address();
159:
160: foreach ($masterconfig as $config) {
161: $cert = array();
162: $site = basename($config);
163: if (!file_exists('/etc/httpd/conf/' . $site . '.ssl')) {
164: return $sitecerts;
165: }
166: $file = '/etc/httpd/conf/virtual/' . $site;
167: if (!file_exists($file)) {
168: continue;
169: }
170: $config = file_get_contents($file);
171: $newcert = $parser($config);
172: if (!$newcert) {
173: continue;
174: }
175: $cert = array_merge($cert, $newcert);
176: $sslextra = '/etc/httpd/conf/' . basename($file) . '.ssl/custom';
177: if (file_exists($sslextra)) {
178: $config = file_get_contents($sslextra);
179: $cert = array_merge($cert, $parser($config));
180: }
181: // remove port info
182: if (isset($cert['host'])) {
183: $tmp = strpos($cert['host'], ':');
184: if ($tmp) {
185: $cert['host'] = substr($cert['host'], 0, $tmp);
186: }
187: } else {
188: $cert['host'] = $accountaddr[0];
189: }
190: $sitecerts[] = $cert;
191: }
192:
193: return $sitecerts;
194: }
195:
196: public function key_exists($key = 'server.key')
197: {
198: if (!IS_CLI) {
199: return $this->query('ssl_key_exists', $key);
200: }
201: // default key name
202: $name = basename($key, '.key');
203: if ($this->permission_level & PRIVILEGE_SITE) {
204: $key = $this->domain_fs_path() . self::KEY_PATH .
205: '/' . $name . '.key';
206: } else {
207: if ($key[0] !== '/') {
208: $key = self::KEY_PATH . '/' . $name;
209: }
210: }
211:
212: return file_exists($key);
213: }
214:
215: public function install($key, $cert, $chain = null)
216: {
217: if (!IS_CLI) {
218: return $this->query('ssl_install', $key, $cert, $chain);
219: }
220: if (!$this->permitted()) {
221: return error('SSL not permitted on account');
222: }
223:
224: if (!$this->valid($cert, $key)) {
225: return error('certificate is not valid for given key: %s', openssl_error_string());
226: }
227:
228:
229: if ($this->is_self_signed($cert)) {
230: $chain = null;
231: } else if (!$chain) {
232: // try to resolve hierarchy
233: $supplemental = $this->resolve_chain($cert);
234: if (!$supplemental) {
235: return error('certificate chain is irresolvable');
236: }
237: info('downloaded chain certificates to satisfy requirement, one or more additional pathways may be missing');
238: $chain = join("\n", $supplemental);
239: } else if (!$this->verify_certificate_chain($cert, $chain)) {
240: return error('chain not valid for certificate');
241: }
242:
243: $this->file_purge();
244: $prefix = $this->domain_fs_path();
245: $crtfile = $prefix . self::CRT_PATH . '/server.crt';
246: $keyfile = $prefix . self::KEY_PATH . '/server.key';
247: // build up in case Ensim is being stupid
248: $this->file_shadow_buildup_backend(
249: $prefix . self::CSR_PATH . '/server.csr'
250: );
251: // cert overwritten or moved
252: $overwrite = false;
253: // backup just in case
254: foreach (array($crtfile, $keyfile) as $file) {
255: /**
256: * make sure its constituents exist
257: * overlayfs ghosts merged layer if r/w doesn't contain
258: * parent dir
259: */
260: $this->file_shadow_buildup_backend($file);
261: $dir = dirname($file);
262: if (!is_dir($dir)) {
263: \Opcenter\Filesystem::mkdir($dir, 'root', $this->group_id, 0700);
264: } else if (file_exists($file)) {
265: $overwrite = true;
266: $old = file_get_contents($file);
267: file_put_contents($file . '-old', $old, LOCK_EX);
268: }
269: }
270: $this->file_purge();
271: if (!file_put_contents($crtfile, $cert, LOCK_EX) || !file_put_contents($keyfile, $key, LOCK_EX)) {
272: error("Unable to install certificate. Is account over storage quota?");
273: if (!$overwrite) {
274: return false;
275: }
276:
277: // unwind process
278: foreach ([$crtfile, $keyfile] as $file) {
279: rename($file . '-old', $file);
280: }
281:
282: return false;
283: }
284: if (FILESYSTEM_TYPE !== 'xfs') {
285: // xfs applies strict quota restrictions, root cannot bypass
286: // quota enforcement
287: chgrp($crtfile, $this->group_id);
288: chgrp($keyfile, $this->group_id);
289: }
290: chmod($crtfile, 0600);
291: chown($crtfile, 'root');
292: chmod($keyfile, 0600);
293: chown($keyfile, 'root');
294:
295: $chainconfig = $this->_getSSLExtraConfig();
296: $chainfile = join(DIRECTORY_SEPARATOR, array($prefix, self::CRT_PATH, 'bundle.crt'));
297: if ($chain) {
298: if (!file_exists(dirname($chainconfig))) {
299: mkdir(dirname($chainconfig), 0711);
300: }
301: file_put_contents($prefix . self::CRT_PATH . '/bundle.crt', $chain, LOCK_EX);
302: if (file_exists($chainconfig)) {
303: $contents = file($chainconfig, FILE_IGNORE_NEW_LINES);
304: $newcontents = array();
305: $directive = 'SSLCertificateChainFile';
306: foreach ($contents as $line) {
307:
308: if (0 === strpos($line, $directive)) {
309: continue;
310: }
311: $newcontents[] = $line;
312: }
313: $newcontents[] = $directive . ' ' . $chainfile;
314: file_put_contents($chainconfig, join("\n", $newcontents));
315: // bundle perms don't really matter since it's public knowledge
316: } else {
317: file_put_contents($chainconfig, 'SSLCertificateChainFile ' . $chainfile);
318: }
319: } else if (file_exists($chainconfig)) {
320: unlink($chainconfig);
321: // $chainfile name is hardcoded. We could arbitrarily unlink whatever chain is specified
322: // for thoroughness at the expense of wiping out a custom setting.
323: if (file_exists($chainfile)) {
324: unlink($chainfile);
325: }
326: }
327:
328: // pre-flight checks done, let's install
329: if (!$overwrite || !$this->enabled()) {
330: $cmd = new Util_Account_Editor($this->getAuthContext()->getAccount(), $this->getAuthContext());
331: $cmd->setConfig(SiteConfiguration::getModuleRemap('openssl'), 'enabled', 1);
332: // ensure HTTP config is rebuild
333: $cmd->edit();
334: }
335: $this->file_purge();
336: // "letsencrypt" reason is user SSL, "ssl" is system SSL
337: \Util_Account_Hooks::instantiateContexted($this->getAuthContext())->run('reload', [self::USER_RHOOK]);
338: info('reloading web server in 2 minutes, stay tuned!');
339:
340: return true;
341: }
342:
343: public function permitted()
344: {
345: return true;
346: }
347:
348: /**
349: * Verify that the named certificate and key
350: *
351: * @param string $cert x509 certificate
352: * @param string $pkey private key
353: * @return bool
354: */
355: public function valid($cert, $pkey)
356: {
357: return openssl_x509_check_private_key($cert, $pkey);
358: }
359:
360: /**
361: * Check if certificate issuer matches requestor
362: *
363: * @param $crt
364: * @return bool|void
365: */
366: public function is_self_signed($crt)
367: {
368: return Ssl::selfSigned($crt);
369: }
370:
371: /**
372: * Create a self-signed certificate
373: *
374: * @param string $cn
375: * @param array $sans
376: * @return bool
377: */
378: public function self_sign(string $cn, array $sans = []): bool
379: {
380: if ($this->cert_exists() && !$this->is_self_signed($this->get_certificate())) {
381: return error('Certificate already exists and is not self-signed');
382: }
383: return serial(function() use($cn, $sans) {
384: $key = $this->generate_privatekey(2048);
385: $csr = $this->generate_csr($key, $cn, null, null, null, null, null, null, $sans);
386: $crt = $this->sign_certificate($csr, $key);
387: return $this->install($key, $crt);
388: }) ?? false;
389:
390: }
391:
392: /**
393: * Parse certificate and return information
394: *
395: * @param mixed $crt resource pointed by openssl_x509_read or string
396: * @return array
397: */
398: public function parse_certificate($crt)
399: {
400: return Ssl::parse($crt);
401: }
402:
403: /**
404: * Resolve a certificate chain, downloading certificates as necessary
405: *
406: * @param string $crt initial certificate
407: * @return bool|string
408: */
409: public function resolve_chain($crt)
410: {
411: $buffer = Error_Reporter::flush_buffer();
412: // error out if any resolution fails
413: $chain = $this->_resolveChain($crt, array());
414: $isError = Error_Reporter::is_error();
415: Error_Reporter::merge_buffer($buffer);
416: if ($isError) {
417: return false;
418: }
419: // remove initial cert returning
420: // resulting chain
421:
422: return join("\n", $chain);
423:
424: }
425:
426: private function _resolveChain($crt, $seen)
427: {
428: /**
429: * Some vendors, like GeoTrust supply a DER-formatted certificate
430: */
431: if (Ssl::isDer($crt)) {
432: $crt = Ssl::der2Pem($crt);
433: }
434:
435: if ($this->is_self_signed($crt)) {
436: // terminated endpoint
437: return array($crt);
438: }
439: $info = $this->parse_certificate($crt);
440:
441: if (!isset($info['extensions'])) {
442: return array();
443: } else if (!isset($info['extensions']['subjectKeyIdentifier'])) {
444: error('missing subjectKeyIdentifier fingerprint!');
445: }
446: $fingerprint = $info['extensions']['subjectKeyIdentifier'];
447:
448: if (array_search($fingerprint, $seen, true)) {
449: return error('chain loop detected, fingerprint: %s', $fingerprint);
450: }
451: $seen[] = $fingerprint;
452:
453: $extensions = $info['extensions'];
454: if (!isset($extensions['authorityInfoAccess'])) {
455: // no further keys
456: return array();
457: }
458:
459: if (!preg_match_all(Regex::SSL_CRT_URI, $extensions['authorityInfoAccess'], $matches)) {
460: error("can't find URI to match in authorityInfoAccess: %s",
461: $extensions['authorityInfoAccess']);
462:
463: return array();
464: }
465:
466: // in certain situations, OCSP is prefixed with URI, defeating the regex
467: // so a second pass to look for a non-OCSP URL
468: $url = $matches['url'][0];
469: foreach ($matches['url'] as $candidate) {
470: if (false !== stripos($candidate, 'ocsp')) {
471: continue;
472: }
473: $url = $candidate;
474: }
475:
476: $chainedcrt = $this->_downloadChain($url);
477: if (!$chainedcrt) {
478: error('failed to resolve chain!');
479:
480: return array();
481: }
482: info("downloaded extra chain `%s'", $url);
483: if (Ssl::isDer($chainedcrt)) {
484: $chainedcrt = Ssl::der2Pem($chainedcrt);
485: }
486: return array_merge(
487: $this->_resolveChain($chainedcrt, $seen),
488: (array)$chainedcrt
489: );
490: }
491:
492: /**
493: * Download a certificate to resolve a chain
494: *
495: * @param $url
496: * @return mixed
497: * @throws Exception
498: */
499: private function _downloadChain($url)
500: {
501: if (extension_loaded('curl')) {
502: $adapter = new HTTP_Request2_Adapter_Curl();
503: } else {
504: $adapter = new HTTP_Request2_Adapter_Socket();
505: }
506:
507: $http = new HTTP_Request2(
508: $url,
509: HTTP_Request2::METHOD_GET,
510: array(
511: 'adapter' => $adapter
512: )
513: );
514:
515: try {
516: $response = $http->send();
517: $code = $response->getStatus();
518: switch ($code) {
519: case 200:
520: break;
521: case 403:
522: return error('URL request forbidden by server');
523: case 404:
524: return error('URL not found on server');
525: case 302:
526: $newLocation = $response->getHeader('location');
527:
528: return $this->_downloadChain($newLocation);
529: default:
530: return error("URL request failed, code `%d': %s",
531: $code, $response->getReasonPhrase());
532: }
533: // this returns nothing as xfer is saved directly to disk
534: $cert = $response->getBody();
535: } catch (HTTP_Request2_Exception $e) {
536: return error("fatal error retrieving URL: `%s'", $e->getMessage());
537: }
538:
539: return $cert;
540: }
541:
542: /**
543: * Verify cert2 is a chain to cert1
544: *
545: * @param mixed $cert1 ssl certificate
546: * @param mixed $cert2 ssl certificate
547: * @return int 1 if cert2 is intermediate of cert1, -1 if cert1 intermediate of cert2, 0 if no match
548: */
549: public function verify_certificate_chain($cert1, $cert2)
550: {
551: $resp = $this->_verify_certificate_chain_real($cert1, $cert2);
552: if ($resp || null === $resp) {
553: return (int)$resp;
554: }
555:
556: return $this->_verify_certificate_chain_real($cert2, $cert1) ? -1 : 0;
557: }
558:
559: /**
560: * Actual chain verification logic
561: *
562: * @param mixed $cert1
563: * @param mixed $cert2
564: * @return int|null
565: */
566: private function _verify_certificate_chain_real($cert1, $cert2)
567: {
568: // basicConstraints: CA:TRUE or FALSE
569: // if CA:FALSE, authorityKeyIdentifier refers to chain
570: // if CA:TRUE, subjectKeyIdentifier == crt authorityKeyIdentifier
571:
572: $icert = $this->parse_certificate($cert1);
573: $ichain = $this->parse_certificate($cert2);
574: if (!isset($ichain['extensions'])) {
575: return null;
576: }
577: $keyidentifier = array_get($icert, 'extensions.authorityKeyIdentifier', '');
578: if (0 === strncmp($keyidentifier, "keyid:", 6)) {
579: $keyidentifier = trim(substr($keyidentifier, 6));
580: }
581: if ($keyidentifier == $ichain['extensions']['subjectKeyIdentifier']) {
582: return 1;
583: }
584:
585: return 0;
586: }
587:
588: private function _getSSLExtraConfig()
589: {
590: return $this->web_site_config_dir() . '.ssl/custom';
591: }
592:
593: public function enabled(): bool
594: {
595: return (bool)$this->getServiceValue(SiteConfiguration::getModuleRemap('openssl'), 'enabled');
596: }
597:
598: public function delete($key, $crt, $chain = null)
599: {
600: if (!IS_CLI) {
601: return $this->query('ssl_delete', $key, $crt, $chain);
602: }
603: // flipped argument order
604: if (substr($key, -4) == '.crt' && substr($crt, -4) == '.key') {
605: $tmp = $crt;
606: $crt = $key;
607: $key = $tmp;
608: }
609: if (!$this->get_certificate($crt)) {
610: return error("invalid certificate `%s' specified", $crt);
611: } else if (!$this->get_private_key($key)) {
612: return error("invalid private key `%s' specified", $key);
613: }
614: if ($chain && !$this->get_certificate($chain)) {
615: return error("invalid certificate chain `%s' specified", $chain);
616: }
617: if (!$this->_delete_wrapper($crt)) {
618: // return on crt, since http config builder depends on .crt
619: // presence to include SSL support
620: return error("failed to delete certificate `%s'", $crt);
621: }
622:
623: if (!$this->_delete_wrapper($key)) {
624: warn("failed to remove ssl key `%s'", $key);
625: }
626:
627: if ($chain && !$this->_delete_wrapper($chain)) {
628: warn("failed to remove ssl chain certficiate `%s'", $chain);
629: }
630: $sslextra = $this->_getSSLExtraConfig();
631:
632: if (file_exists($sslextra)) {
633: $contents = file_get_contents($sslextra);
634: $newconfig = array();
635: foreach (explode("\n", $contents) as $line) {
636: if (preg_match('!/' . preg_quote($chain, '!') . '$!', $line)) {
637: info('detected and removed certificate chain from http config');
638: continue;
639: }
640: $newconfig[] = $line;
641: }
642: file_put_contents($sslextra, join("\n", $newconfig));
643: }
644: // reload HTTP server and rebuild config
645: $editor = new Util_Account_Editor($this->getAuthContext()->getAccount());
646: $editor->setConfig(SiteConfiguration::getModuleRemap('openssl'), 'enabled', 0);
647: $status = $editor->edit();
648: if (!$status) {
649: return error('failed to deactivate openssl on account');
650: }
651: Util_Account_Hooks::instantiateContexted($this->getAuthContext())->run('reload', [self::USER_RHOOK]);
652: return true;
653: }
654:
655: /**
656: * Get raw certificate
657: *
658: * @param string $name certificate name
659: * @return bool|string
660: */
661: public function get_certificate($name = 'server.crt')
662: {
663: if (!IS_CLI) {
664: return $this->query('ssl_get_certificate', $name);
665: }
666: $name = basename($name);
667: if (!str_ends_with($name, '.crt') && !str_ends_with($name, '.pem')) {
668: $name .= '.crt';
669: }
670: if ($this->permission_level & PRIVILEGE_SITE) {
671: $file = $this->domain_fs_path() . self::CRT_PATH .
672: '/' . $name;
673: } else if ($name[0] != '/') {
674: $file = Opcenter\Http\Apache::HTTP_HOME . '/conf/' . $name;
675: } else {
676: $file = $name . '.crt';
677: }
678:
679: if (!file_exists($file)) {
680: return error("certificate `%s' does not exist", $name);
681: }
682:
683: return file_get_contents($file);
684: }
685:
686: public function get_private_key($name = 'server.key')
687: {
688: if (!IS_CLI) {
689: return $this->query('ssl_get_private_key', $name);
690: }
691: $name = basename($name, '.key');
692: if ($this->permission_level & PRIVILEGE_SITE) {
693: $file = $this->domain_fs_path() . self::KEY_PATH .
694: '/' . $name . '.key';
695: } else {
696: if ($name[0] != '/') {
697: $file = self::KEY_PATH . $name . '.key';
698: } else {
699: $file = $name . '.key';
700: }
701: }
702:
703: if (!file_exists($file)) {
704: return error("private key `%s' does not exist", $name);
705: }
706:
707: return file_get_contents($file);
708: }
709:
710: private function _delete_wrapper($file)
711: {
712: $prefix = $this->domain_fs_path();
713: $ext = substr($file, -4);
714: switch ($ext) {
715: case '.key':
716: $folder = self::KEY_PATH;
717: break;
718: case '.csr':
719: $folder = self::CSR_PATH;
720: break;
721: case '.crt':
722: $folder = self::CRT_PATH;
723: break;
724: default:
725: return error("cannot delete SSL asset: unknown extension `%s'", $ext);
726: }
727: $file = join(DIRECTORY_SEPARATOR, array($prefix, $folder, $file));
728: if (!file_exists($file)) {
729: return false;
730: }
731:
732: return unlink($file);
733: }
734:
735: /**
736: * Generate new private key
737: *
738: * @param int $bits
739: * @return string
740: */
741: public function generate_privatekey($bits = 2048)
742: {
743: return Ssl::genkey($bits);
744: }
745:
746: /**
747: * Generate certificate signing request for a CA
748: *
749: * @param string $privkey private key
750: * @param string $host common name for which the SSL certificate is valid
751: * @param string|null $country 2-letter country code
752: * @param string|null $state state
753: * @param string|null $locality city/province
754: * @param string|null $org optional organization
755: * @param string|null $orgunit optional organizational unit (company section)
756: * @param string|null $email contact e-mail
757: * @param array $san x509 subject alternate names
758: * @return bool|string certificate signing request
759: */
760: public function generate_csr(
761: string $privkey,
762: string $host,
763: ?string $country = '',
764: ?string $state = '',
765: ?string $locality = '',
766: ?string $org = '',
767: ?string $orgunit = '',
768: ?string $email = '',
769: array $san = []
770: ) {
771: return Ssl::generate_csr(
772: $privkey, $host, $country ?? 'US', $state ?? 'GA', $locality ?? 'Atlanta', (string)$org, (string)$orgunit, (string)$email, $san
773: );
774: }
775:
776: /**
777: * Get certificate signing request parameters
778: *
779: * Sample response:
780: * array(7) {
781: * ["C"]=>
782: * string(2) "US"
783: * ["ST"]=>
784: * string(7) "Georgia"
785: * ["L"]=>
786: * string(7) "Lilburn"
787: * ["O"]=>
788: * string(13) "Apis Networks"
789: * ["OU"]=>
790: * string(4) "Test"
791: * ["CN"]=>
792: * string(8) "test.com"
793: * ["emailAddress"]=>
794: * string(25) "msaladna@apisnetworks.com"
795: * }
796: *
797: * @param string $csr
798: * @return array req parameters using shorthand notation
799: */
800: public function request_info($csr)
801: {
802: return Ssl::request_info($csr);
803: }
804:
805: /**
806: * Get public key from certificate
807: *
808: * Array (
809: * [bits] => 4096
810: * [key] => -----BEGIN PUBLIC KEY-----
811: * ...
812: * ...
813: * [rsa] => Array ( [n] => .., [e] => ..,)
814: * [type] => 0
815: *
816: * @param string $name certificate name
817: * @return array|bool
818: */
819: public function get_public_key($name)
820: {
821: if (!IS_CLI) {
822: return $this->query('ssl_get_public_key', $name);
823: }
824: $name = basename($name, '.key');
825: $key = $this->get_certificate($name);
826: if (!$key) {
827: return error("unable to get named certificate `%s'", $name);
828: }
829: $res = openssl_pkey_get_public($key);
830: $details = openssl_pkey_get_details($res);
831: openssl_pkey_free($res);
832:
833: return $details;
834:
835: }
836:
837: /**
838: * Order a mixed arrangement of certificates in ascending order to root
839: *
840: * @param array $certs
841: * @return array
842: */
843: public function order_certificates(array $certs)
844: {
845: foreach ($certs as $cert) {
846:
847: }
848: }
849:
850: public function get_csr($name)
851: {
852: if (!IS_CLI) {
853: return $this->query('ssl_get_csr', $name);
854: }
855: $name = basename($name, '.csr');
856: if ($this->permission_level & PRIVILEGE_SITE) {
857: $file = $this->domain_fs_path() . self::CSR_PATH .
858: '/' . $name . '.csr';
859: } else {
860: if ($name[0] != '/') {
861: $file = self::CSR_PATH . $name . '.csr';
862: } else {
863: $file = $name . '.csr';
864: }
865: }
866:
867: if (!file_exists($file)) {
868: return error("certificate request `%s' does not exist", $name);
869: }
870:
871: return file_get_contents($file);
872:
873: }
874:
875: /**
876: * Create a self-signed certificate
877: *
878: * @param string $csr certificate signing request {@link generate_csr}
879: * @param string $privkey private key to sign certificate
880: * @param int $days number days valid
881: * @param float $serial serial number
882: * @return string signed certificate
883: */
884: public function sign_certificate(
885: $csr,
886: $privkey,
887: $days = 365,
888: $serial = null
889: ) {
890:
891: return Ssl::selfsign($csr, $privkey, $days, $serial);
892: }
893:
894: /**
895: * Verify the given private key matches the self-signed certificate
896: *
897: * @param string $crt
898: * @param string $privkey
899: * @return bool
900: */
901: public function verify_x509_key($crt, $privkey)
902: {
903: return openssl_x509_check_private_key($crt, $privkey);
904: }
905:
906: public function verify_key($key)
907: {
908: if (!$key) {
909: return error('no key specified');
910: }
911: $info = $this->privkey_info($key);
912: if (!$info) {
913: return error('invalid key detected');
914: }
915:
916: return true;
917: }
918:
919: /**
920: * Get private key details
921: *
922: * @param $privkey
923: * @return array
924: */
925: public function privkey_info($privkey)
926: {
927: $res = openssl_pkey_get_private($privkey);
928: $details = openssl_pkey_get_details($res);
929:
930: return $details;
931: }
932:
933: /**
934: * Get hostnames for which a certificate is valid
935: *
936: * @param resource|string $certificate
937: * @return array
938: */
939: public function get_alternative_names($certificate): ?array
940: {
941: return Ssl::alternativeNames($certificate);
942: }
943:
944: public function _create()
945: {
946: $this->_edit();
947: }
948:
949: /**
950: * Active certificate contains name
951: *
952: * @param string $name
953: * @return bool
954: */
955: public function contains_cn(string $name): bool
956: {
957: if (!$this->cert_exists()) {
958: return false;
959: }
960:
961: $certdata = $this->ssl_get_certificates();
962: $certdata = array_pop($certdata);
963: $cert = $this->ssl_get_certificate($certdata['crt']);
964: $sans = $this->ssl_get_alternative_names($cert);
965: $name = strtolower($name);
966: if (in_array($name, $sans, true)) {
967: return true;
968: }
969:
970: $offset = 0;
971: while (false !== ($offset = strpos($name, '.'))) {
972: $name = substr($name, $offset ? $offset + 1 : 0);
973: if (in_array("*.${name}", $sans, true)) {
974: return true;
975: }
976: }
977:
978: return false;
979: }
980:
981: /**
982: * Retrieve server certificate
983: *
984: * @return string|null
985: */
986: public function server_certificate(): ?string
987: {
988: if (!IS_CLI) {
989: return $this->query('ssl_server_certificate');
990: }
991:
992: if (!file_exists(Ssl::systemCertificatePath())) {
993: return null;
994: }
995:
996: $pem = file_get_contents(Ssl::systemCertificatePath());
997: return Ssl::extractCertificate($pem);
998: }
999:
1000: public function _edit()
1001: {
1002: $conf_new = $this->getAuthContext()->getAccount()->new;
1003: $conf_old = $this->getAuthContext()->getAccount()->old;
1004: $domainprefix = $this->domain_fs_path();
1005: $renameWrapper = function ($mode) use ($domainprefix) {
1006: $certdir = $domainprefix . self::CRT_PATH;
1007: if ($mode === 'disable') {
1008: foreach (glob($certdir . '/*.crt') as $cert) {
1009: rename($cert, $cert . '-disabled');
1010: info('disabled certificate ' . basename($cert));
1011: }
1012:
1013: return;
1014: }
1015: $pkeyfile = $domainprefix . self::KEY_PATH . '/server.key';
1016: if (!file_exists($pkeyfile)) {
1017: // cert won't work without private key
1018: return false;
1019: }
1020: $pkey = file_get_contents($pkeyfile);
1021: foreach (glob($certdir . '/*.crt-disabled') as $cert) {
1022: $crt = file_get_contents($cert);
1023: $file = basename($cert);
1024: // server.crt is hardcoded SSL CRT
1025: if ($file === 'server.crt' && !$this->valid($crt, $pkey)) {
1026: info("removing dangling certificate `%s' that does not match pkey modulus", $cert);
1027: unlink($cert);
1028: // using certificate will break site
1029: continue;
1030: }
1031: rename($cert, substr($cert, 0, -9));
1032: info('enabled certificate ' . substr(basename($cert), 0, -9));
1033: }
1034: };
1035:
1036: $ssl = SiteConfiguration::getModuleRemap('openssl');
1037: // Luna and on do things differently
1038: if (!$conf_new[$ssl]['enabled']) {
1039: $renameWrapper('disable');
1040: } else if ($conf_new[$ssl]['enabled'] && !($conf_old[$ssl]['enabled'] ?? false)) {
1041: $renameWrapper('enable');
1042: }
1043: }
1044:
1045: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
1046: {
1047: return true;
1048: }
1049:
1050: public function _delete()
1051: {
1052: // TODO: Implement _delete() method.
1053: }
1054:
1055: public function _create_user(string $user)
1056: {
1057: // TODO: Implement _create_user() method.
1058: }
1059:
1060: public function _delete_user(string $user)
1061: {
1062: // TODO: Implement _delete_user() method.
1063: }
1064:
1065: public function _edit_user(string $userold, string $usernew, array $oldpwd)
1066: {
1067: // TODO: Implement _edit_user() method.
1068: }
1069:
1070:
1071: }