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