CertificateManager.php 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  6. * SPDX-License-Identifier: AGPL-3.0-only
  7. */
  8. namespace OC\Security;
  9. use OC\Files\View;
  10. use OCP\ICertificate;
  11. use OCP\ICertificateManager;
  12. use OCP\IConfig;
  13. use OCP\Security\ISecureRandom;
  14. use Psr\Log\LoggerInterface;
  15. /**
  16. * Manage trusted certificates for users
  17. */
  18. class CertificateManager implements ICertificateManager {
  19. private ?string $bundlePath = null;
  20. public function __construct(
  21. protected View $view,
  22. protected IConfig $config,
  23. protected LoggerInterface $logger,
  24. protected ISecureRandom $random,
  25. ) {
  26. }
  27. /**
  28. * Returns all certificates trusted by the user
  29. *
  30. * @return \OCP\ICertificate[]
  31. */
  32. public function listCertificates(): array {
  33. if (!$this->config->getSystemValueBool('installed', false)) {
  34. return [];
  35. }
  36. $path = $this->getPathToCertificates() . 'uploads/';
  37. if (!$this->view->is_dir($path)) {
  38. return [];
  39. }
  40. $result = [];
  41. $handle = $this->view->opendir($path);
  42. if (!is_resource($handle)) {
  43. return [];
  44. }
  45. while (false !== ($file = readdir($handle))) {
  46. if ($file != '.' && $file != '..') {
  47. try {
  48. $content = $this->view->file_get_contents($path . $file);
  49. if ($content !== false) {
  50. $result[] = new Certificate($content, $file);
  51. } else {
  52. $this->logger->error("Failed to read certificate from $path");
  53. }
  54. } catch (\Exception $e) {
  55. $this->logger->error("Failed to read certificate from $path", ['exception' => $e]);
  56. }
  57. }
  58. }
  59. closedir($handle);
  60. return $result;
  61. }
  62. private function hasCertificates(): bool {
  63. if (!$this->config->getSystemValueBool('installed', false)) {
  64. return false;
  65. }
  66. $path = $this->getPathToCertificates() . 'uploads/';
  67. if (!$this->view->is_dir($path)) {
  68. return false;
  69. }
  70. $result = [];
  71. $handle = $this->view->opendir($path);
  72. if (!is_resource($handle)) {
  73. return false;
  74. }
  75. while (false !== ($file = readdir($handle))) {
  76. if ($file !== '.' && $file !== '..') {
  77. return true;
  78. }
  79. }
  80. closedir($handle);
  81. return false;
  82. }
  83. /**
  84. * create the certificate bundle of all trusted certificated
  85. */
  86. public function createCertificateBundle(): void {
  87. $path = $this->getPathToCertificates();
  88. $certs = $this->listCertificates();
  89. if (!$this->view->file_exists($path)) {
  90. $this->view->mkdir($path);
  91. }
  92. $defaultCertificates = file_get_contents(\OC::$SERVERROOT . '/resources/config/ca-bundle.crt');
  93. if (strlen($defaultCertificates) < 1024) { // sanity check to verify that we have some content for our bundle
  94. // log as exception so we have a stacktrace
  95. $e = new \Exception('Shipped ca-bundle is empty, refusing to create certificate bundle');
  96. $this->logger->error($e->getMessage(), ['exception' => $e]);
  97. return;
  98. }
  99. $certPath = $path . 'rootcerts.crt';
  100. $tmpPath = $certPath . '.tmp' . $this->random->generate(10, ISecureRandom::CHAR_DIGITS);
  101. $fhCerts = $this->view->fopen($tmpPath, 'w');
  102. if (!is_resource($fhCerts)) {
  103. throw new \RuntimeException('Unable to open file handler to create certificate bundle "' . $tmpPath . '".');
  104. }
  105. // Write user certificates
  106. foreach ($certs as $cert) {
  107. $file = $path . '/uploads/' . $cert->getName();
  108. $data = $this->view->file_get_contents($file);
  109. if (strpos($data, 'BEGIN CERTIFICATE')) {
  110. fwrite($fhCerts, $data);
  111. fwrite($fhCerts, "\r\n");
  112. }
  113. }
  114. // Append the default certificates
  115. fwrite($fhCerts, $defaultCertificates);
  116. // Append the system certificate bundle
  117. $systemBundle = $this->getCertificateBundle();
  118. if ($systemBundle !== $certPath && $this->view->file_exists($systemBundle)) {
  119. $systemCertificates = $this->view->file_get_contents($systemBundle);
  120. fwrite($fhCerts, $systemCertificates);
  121. }
  122. fclose($fhCerts);
  123. $this->view->rename($tmpPath, $certPath);
  124. }
  125. /**
  126. * Save the certificate and re-generate the certificate bundle
  127. *
  128. * @param string $certificate the certificate data
  129. * @param string $name the filename for the certificate
  130. * @throws \Exception If the certificate could not get added
  131. */
  132. public function addCertificate(string $certificate, string $name): ICertificate {
  133. $path = $this->getPathToCertificates() . 'uploads/' . $name;
  134. $directory = dirname($path);
  135. $this->view->verifyPath($directory, basename($path));
  136. $this->bundlePath = null;
  137. if (!$this->view->file_exists($directory)) {
  138. $this->view->mkdir($directory);
  139. }
  140. try {
  141. $certificateObject = new Certificate($certificate, $name);
  142. $this->view->file_put_contents($path, $certificate);
  143. $this->createCertificateBundle();
  144. return $certificateObject;
  145. } catch (\Exception $e) {
  146. throw $e;
  147. }
  148. }
  149. /**
  150. * Remove the certificate and re-generate the certificate bundle
  151. */
  152. public function removeCertificate(string $name): bool {
  153. $path = $this->getPathToCertificates() . 'uploads/' . $name;
  154. try {
  155. $this->view->verifyPath(dirname($path), basename($path));
  156. } catch (\Exception) {
  157. return false;
  158. }
  159. $this->bundlePath = null;
  160. if ($this->view->file_exists($path)) {
  161. $this->view->unlink($path);
  162. $this->createCertificateBundle();
  163. }
  164. return true;
  165. }
  166. /**
  167. * Get the path to the certificate bundle
  168. */
  169. public function getCertificateBundle(): string {
  170. return $this->getPathToCertificates() . 'rootcerts.crt';
  171. }
  172. /**
  173. * Get the full local path to the certificate bundle
  174. * @throws \Exception when getting bundle path fails
  175. */
  176. public function getAbsoluteBundlePath(): string {
  177. try {
  178. if ($this->bundlePath === null) {
  179. if (!$this->hasCertificates()) {
  180. $this->bundlePath = \OC::$SERVERROOT . '/resources/config/ca-bundle.crt';
  181. } else {
  182. if ($this->needsRebundling()) {
  183. $this->createCertificateBundle();
  184. }
  185. $certificateBundle = $this->getCertificateBundle();
  186. $this->bundlePath = $this->view->getLocalFile($certificateBundle) ?: null;
  187. if ($this->bundlePath === null) {
  188. throw new \RuntimeException('Unable to get certificate bundle "' . $certificateBundle . '".');
  189. }
  190. }
  191. }
  192. return $this->bundlePath;
  193. } catch (\Exception $e) {
  194. $this->logger->error('Failed to get absolute bundle path. Fallback to default ca-bundle.crt', ['exception' => $e]);
  195. return \OC::$SERVERROOT . '/resources/config/ca-bundle.crt';
  196. }
  197. }
  198. private function getPathToCertificates(): string {
  199. return '/files_external/';
  200. }
  201. /**
  202. * Check if we need to re-bundle the certificates because one of the sources has updated
  203. */
  204. private function needsRebundling(): bool {
  205. $targetBundle = $this->getCertificateBundle();
  206. if (!$this->view->file_exists($targetBundle)) {
  207. return true;
  208. }
  209. $sourceMTime = $this->getFilemtimeOfCaBundle();
  210. return $sourceMTime > $this->view->filemtime($targetBundle);
  211. }
  212. /**
  213. * get mtime of ca-bundle shipped by Nextcloud
  214. */
  215. protected function getFilemtimeOfCaBundle(): int {
  216. return filemtime(\OC::$SERVERROOT . '/resources/config/ca-bundle.crt');
  217. }
  218. }